chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,506 @@
package account
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"telesrv/internal/domain"
)
const (
maxBusinessLocationAddress = 96
maxBusinessIntroTitle = 64
maxBusinessIntroDesc = 160
)
func (s *Service) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessProfile{UserID: userID}, false, nil
}
return s.business.GetBusinessProfile(ctx, userID)
}
func (s *Service) UpdateBusinessWorkHours(ctx context.Context, userID int64, hours *domain.BusinessWorkHours) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessWorkHours(hours)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.WorkHours = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessLocation(ctx context.Context, userID int64, location *domain.BusinessLocation) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessLocation(location)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Location = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessIntro(ctx context.Context, userID int64, intro *domain.BusinessIntro) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessIntro(intro)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Intro = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessGreetingMessage(ctx context.Context, userID int64, greeting *domain.BusinessGreetingMessage) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessGreeting(greeting)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Greeting = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessAwayMessage(ctx context.Context, userID int64, away *domain.BusinessAwayMessage) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessAway(away)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Away = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) ListBusinessChatLinks(ctx context.Context, userID int64) ([]domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return nil, nil
}
return s.business.ListBusinessChatLinks(ctx, userID)
}
func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessChatLink{}, domain.ErrPremiumRequired
}
normalized, err := domain.NormalizeBusinessChatLinkInput(input)
if err != nil {
return domain.BusinessChatLink{}, err
}
now := time.Now().Unix()
for i := 0; i < 8; i++ {
slug, err := randomBusinessChatLinkSlug()
if err != nil {
return domain.BusinessChatLink{}, err
}
link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{
OwnerUserID: userID,
Slug: slug,
Link: businessChatLinkURL(slug),
Message: normalized.Message,
Entities: normalized.Entities,
Title: normalized.Title,
CreatedAt: now,
UpdatedAt: now,
})
if err == nil {
return link, nil
}
if !errors.Is(err, domain.ErrBusinessChatLinkInvalid) {
return domain.BusinessChatLink{}, err
}
}
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid
}
func (s *Service) EditBusinessChatLink(ctx context.Context, userID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessChatLink{}, domain.ErrPremiumRequired
}
normalized, err := domain.NormalizeBusinessChatLinkInput(input)
if err != nil {
return domain.BusinessChatLink{}, err
}
return s.business.UpdateBusinessChatLink(ctx, userID, strings.TrimSpace(slug), normalized)
}
func (s *Service) DeleteBusinessChatLink(ctx context.Context, userID int64, slug string) (bool, error) {
if s == nil || s.business == nil || userID == 0 {
return false, domain.ErrBusinessChatLinkNotFound
}
return s.business.DeleteBusinessChatLink(ctx, userID, strings.TrimSpace(slug))
}
func (s *Service) ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) {
if s == nil || s.business == nil {
return domain.BusinessChatLink{}, false, nil
}
return s.business.ResolveBusinessChatLink(ctx, strings.TrimSpace(slug), bumpViews)
}
func (s *Service) GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 {
return domain.ConnectedBusinessBot{}, false, nil
}
return s.business.GetConnectedBusinessBot(ctx, ownerUserID)
}
func (s *Service) SaveConnectedBusinessBot(ctx context.Context, ownerUserID int64, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) {
if s == nil || s.business == nil || ownerUserID == 0 || bot.BotUserID == 0 || bot.BotUserID == ownerUserID {
return domain.ConnectedBusinessBot{}, domain.ErrBotBusinessMissing
}
recipients, err := normalizeBusinessBotRecipients(bot.Recipients)
if err != nil {
return domain.ConnectedBusinessBot{}, err
}
now := time.Now().Unix()
bot.OwnerUserID = ownerUserID
bot.Recipients = recipients
if bot.CreatedAtUnix == 0 {
bot.CreatedAtUnix = now
}
bot.UpdatedAtUnix = now
return s.business.SaveConnectedBusinessBot(ctx, bot)
}
func (s *Service) DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 || botUserID == 0 {
return false, domain.ErrBotBusinessMissing
}
return s.business.DeleteConnectedBusinessBot(ctx, ownerUserID, botUserID)
}
func (s *Service) SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
} else if !ok {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
state, err := s.business.SetConnectedBusinessBotPaused(ctx, ownerUserID, peerUserID, paused)
if err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
}
if state.UpdatedAtUnix == 0 {
state.UpdatedAtUnix = time.Now().Unix()
}
return state, nil
}
func (s *Service) DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
} else if !ok {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
state, err := s.business.DisableConnectedBusinessBotForPeer(ctx, ownerUserID, peerUserID)
if err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
}
if state.UpdatedAtUnix == 0 {
state.UpdatedAtUnix = time.Now().Unix()
}
return state, nil
}
func (s *Service) GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 {
return domain.ConnectedBusinessBotPeerState{}, false, nil
}
return s.business.GetConnectedBusinessBotPeerState(ctx, ownerUserID, peerUserID)
}
func (s *Service) ListQuickReplies(ctx context.Context, userID int64) (domain.QuickReplyList, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyList{OwnerUserID: userID}, nil
}
return s.business.ListQuickReplies(ctx, userID, true)
}
func (s *Service) CheckQuickReplyShortcut(ctx context.Context, userID int64, shortcut string) (bool, error) {
if s == nil || s.business == nil || userID == 0 {
if _, err := domain.NormalizeQuickReplyShortcut(shortcut); err != nil {
return false, err
}
return true, nil
}
return s.business.CheckQuickReplyShortcut(ctx, userID, shortcut)
}
func (s *Service) SaveQuickReplyText(ctx context.Context, userID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.SaveQuickReplyText(ctx, userID, shortcut, msg)
}
func (s *Service) GetQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid
}
return s.business.GetQuickReplyMessages(ctx, userID, shortcutID, ids)
}
func (s *Service) RenameQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
return s.business.RenameQuickReplyShortcut(ctx, userID, shortcutID, shortcut)
}
func (s *Service) ReorderQuickReplies(ctx context.Context, userID int64, order []int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
return s.business.ReorderQuickReplies(ctx, userID, append([]int(nil), order...))
}
func (s *Service) DeleteQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.DeleteQuickReplyShortcut(ctx, userID, shortcutID)
}
func (s *Service) DeleteQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.DeleteQuickReplyMessages(ctx, userID, shortcutID, append([]int(nil), ids...))
}
func (s *Service) businessProfileForUpdate(ctx context.Context, userID int64) (domain.BusinessProfile, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessProfile{}, domain.ErrPremiumRequired
}
profile, _, err := s.business.GetBusinessProfile(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.UserID = userID
return profile, nil
}
func (s *Service) saveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) (domain.BusinessProfile, error) {
profile.UpdatedAtUnix = time.Now().Unix()
if err := s.business.SaveBusinessProfile(ctx, profile); err != nil {
return domain.BusinessProfile{}, err
}
return profile, nil
}
func normalizeBusinessWorkHours(in *domain.BusinessWorkHours) (*domain.BusinessWorkHours, error) {
if in == nil {
return nil, nil
}
out := *in
out.TimezoneID = strings.TrimSpace(out.TimezoneID)
out.OpenNow = false
if out.TimezoneID == "" || len(out.WeeklyOpen) == 0 || len(out.WeeklyOpen) > domain.MaxBusinessWorkHourIntervals {
return nil, domain.ErrBusinessProfileInvalid
}
out.WeeklyOpen = append([]domain.BusinessWeeklyOpen(nil), out.WeeklyOpen...)
for _, item := range out.WeeklyOpen {
if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 {
return nil, domain.ErrBusinessProfileInvalid
}
}
return &out, nil
}
func normalizeBusinessLocation(in *domain.BusinessLocation) (*domain.BusinessLocation, error) {
if in == nil {
return nil, nil
}
out := *in
out.Address = strings.TrimSpace(out.Address)
if out.Address == "" || utf8.RuneCountInString(out.Address) > maxBusinessLocationAddress {
return nil, domain.ErrBusinessProfileInvalid
}
if out.Geo != nil {
geo := *out.Geo
if geo.Lat < -90 || geo.Lat > 90 || geo.Long < -180 || geo.Long > 180 {
return nil, domain.ErrBusinessProfileInvalid
}
out.Geo = &geo
}
return &out, nil
}
func normalizeBusinessIntro(in *domain.BusinessIntro) (*domain.BusinessIntro, error) {
if in == nil {
return nil, nil
}
out := *in
out.Title = strings.TrimSpace(out.Title)
out.Description = strings.TrimSpace(out.Description)
if out.Title == "" && out.Description == "" && out.StickerDocumentID == 0 {
return nil, nil
}
if utf8.RuneCountInString(out.Title) > maxBusinessIntroTitle || utf8.RuneCountInString(out.Description) > maxBusinessIntroDesc {
return nil, domain.ErrBusinessProfileInvalid
}
return &out, nil
}
func normalizeBusinessGreeting(in *domain.BusinessGreetingMessage) (*domain.BusinessGreetingMessage, error) {
if in == nil {
return nil, nil
}
out := *in
if out.ShortcutID <= 0 || !validGreetingNoActivityDays(out.NoActivityDays) {
return nil, domain.ErrBusinessProfileInvalid
}
recipients, err := normalizeBusinessRecipients(out.Recipients)
if err != nil {
return nil, err
}
out.Recipients = recipients
return &out, nil
}
func normalizeBusinessAway(in *domain.BusinessAwayMessage) (*domain.BusinessAwayMessage, error) {
if in == nil {
return nil, nil
}
out := *in
if out.ShortcutID <= 0 {
return nil, domain.ErrBusinessProfileInvalid
}
switch out.Schedule.Kind {
case domain.BusinessAwayScheduleAlways, domain.BusinessAwayScheduleOutsideWorkHours:
out.Schedule.StartDate = 0
out.Schedule.EndDate = 0
case domain.BusinessAwayScheduleCustom:
if out.Schedule.StartDate <= 0 || out.Schedule.EndDate <= out.Schedule.StartDate {
return nil, domain.ErrBusinessProfileInvalid
}
default:
return nil, domain.ErrBusinessProfileInvalid
}
recipients, err := normalizeBusinessRecipients(out.Recipients)
if err != nil {
return nil, err
}
out.Recipients = recipients
return &out, nil
}
func normalizeBusinessRecipients(in domain.BusinessRecipients) (domain.BusinessRecipients, error) {
out := in
out.Users = append([]int64(nil), in.Users...)
if len(out.Users) > domain.MaxBusinessRecipientUsers {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
seen := make(map[int64]struct{}, len(out.Users))
users := out.Users[:0]
for _, id := range out.Users {
if id <= 0 {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
users = append(users, id)
}
out.Users = users
if !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
return out, nil
}
func normalizeBusinessBotRecipients(in domain.BusinessBotRecipients) (domain.BusinessBotRecipients, error) {
out := in
users, err := dedupeBusinessUserIDs(in.Users)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
excluded, err := dedupeBusinessUserIDs(in.ExcludeUsers)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
if len(users)+len(excluded) > domain.MaxBusinessRecipientUsers {
return domain.BusinessBotRecipients{}, domain.ErrBusinessProfileInvalid
}
if out.ExcludeSelected {
merged := append(users, excluded...)
users, err = dedupeBusinessUserIDs(merged)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
excluded = nil
}
out.Users = users
out.ExcludeUsers = excluded
if !out.ExcludeSelected && !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 {
return domain.BusinessBotRecipients{}, domain.ErrBusinessRecipientsEmpty
}
return out, nil
}
func dedupeBusinessUserIDs(in []int64) ([]int64, error) {
if len(in) == 0 {
return nil, nil
}
seen := make(map[int64]struct{}, len(in))
out := make([]int64, 0, len(in))
for _, id := range in {
if id <= 0 {
return nil, domain.ErrBusinessProfileInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func validGreetingNoActivityDays(days int) bool {
switch days {
case 7, 14, 21, 28:
return true
default:
return false
}
}
func randomBusinessChatLinkSlug() (string, error) {
value, err := randomInt64()
if err != nil {
return "", err
}
return fmt.Sprintf("%x", value), nil
}
func businessChatLinkURL(slug string) string {
return "https://telesrv.net/m/" + slug
}

View file

@ -0,0 +1,158 @@
package account
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestBusinessProfileAndChatLinks(t *testing.T) {
ctx := context.Background()
const userID int64 = 1001
store := memory.NewPasswordStore()
svc := NewService(store, WithBusinessAutomation(store))
profile, err := svc.UpdateBusinessWorkHours(ctx, userID, &domain.BusinessWorkHours{
TimezoneID: "Asia/Shanghai",
WeeklyOpen: []domain.BusinessWeeklyOpen{{
StartMinute: 6*24*60 + 21*60,
EndMinute: 7*24*60 + 4*60,
}},
OpenNow: true,
})
if err != nil {
t.Fatalf("UpdateBusinessWorkHours: %v", err)
}
if profile.WorkHours == nil || profile.WorkHours.OpenNow {
t.Fatalf("WorkHours = %+v, want persisted hours with OpenNow cleared", profile.WorkHours)
}
if _, err := svc.UpdateBusinessLocation(ctx, userID, &domain.BusinessLocation{
Address: "No. 1 Test Road",
Geo: &domain.GeoPoint{Lat: 31.2, Long: 121.5},
}); err != nil {
t.Fatalf("UpdateBusinessLocation: %v", err)
}
if _, err := svc.UpdateBusinessIntro(ctx, userID, &domain.BusinessIntro{
Title: "Support",
Description: "Fast replies",
}); err != nil {
t.Fatalf("UpdateBusinessIntro: %v", err)
}
got, found, err := svc.GetBusinessProfile(ctx, userID)
if err != nil || !found {
t.Fatalf("GetBusinessProfile found=%v err=%v", found, err)
}
if got.Location == nil || got.Intro == nil {
t.Fatalf("profile = %+v, want location and intro", got)
}
link, err := svc.CreateBusinessChatLink(ctx, userID, domain.BusinessChatLinkInput{
Message: "Hello from link",
Title: "Support link",
Entities: []domain.MessageEntity{{
Type: domain.MessageEntityBold,
Offset: 0,
Length: 5,
}},
})
if err != nil {
t.Fatalf("CreateBusinessChatLink: %v", err)
}
if link.Slug == "" || link.Link == "" {
t.Fatalf("created link = %+v, want slug/link", link)
}
links, err := svc.ListBusinessChatLinks(ctx, userID)
if err != nil || len(links) != 1 {
t.Fatalf("ListBusinessChatLinks len=%d err=%v", len(links), err)
}
resolved, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, true)
if err != nil || !found || resolved.Views != 1 {
t.Fatalf("ResolveBusinessChatLink found=%v link=%+v err=%v", found, resolved, err)
}
edited, err := svc.EditBusinessChatLink(ctx, userID, link.Slug, domain.BusinessChatLinkInput{
Message: "Edited",
Title: "New title",
})
if err != nil {
t.Fatalf("EditBusinessChatLink: %v", err)
}
if edited.Message != "Edited" || edited.Title != "New title" {
t.Fatalf("edited link = %+v", edited)
}
deleted, err := svc.DeleteBusinessChatLink(ctx, userID, link.Slug)
if err != nil || !deleted {
t.Fatalf("DeleteBusinessChatLink deleted=%v err=%v", deleted, err)
}
if _, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, false); err != nil || found {
t.Fatalf("Resolve deleted found=%v err=%v", found, err)
}
}
func TestQuickRepliesLifecycle(t *testing.T) {
ctx := context.Background()
const userID int64 = 1002
store := memory.NewPasswordStore()
svc := NewService(store, WithBusinessAutomation(store))
available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello")
if err != nil || !available {
t.Fatalf("CheckQuickReplyShortcut available=%v err=%v", available, err)
}
mutation, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{
RandomID: 11,
Date: 123,
Message: "First template",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}},
})
if err != nil {
t.Fatalf("SaveQuickReplyText first: %v", err)
}
if mutation.Kind != domain.QuickReplyMutationNew || mutation.ShortcutID == 0 || mutation.Message.ID == 0 {
t.Fatalf("first mutation = %+v", mutation)
}
shortcutID := mutation.ShortcutID
second, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{
RandomID: 12,
Date: 124,
Message: "Second template",
})
if err != nil {
t.Fatalf("SaveQuickReplyText second: %v", err)
}
if second.Kind != domain.QuickReplyMutationMessage || second.ShortcutID != shortcutID {
t.Fatalf("second mutation = %+v", second)
}
if available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello"); err != nil || available {
t.Fatalf("CheckQuickReplyShortcut duplicate available=%v err=%v", available, err)
}
list, err := svc.ListQuickReplies(ctx, userID)
if err != nil || len(list.QuickReplies) != 1 || list.QuickReplies[0].Count != 2 || list.Hash == 0 {
t.Fatalf("ListQuickReplies = %+v err=%v", list, err)
}
msgs, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil)
if err != nil || msgs.Count != 2 || len(msgs.Messages) != 2 || msgs.Hash == 0 {
t.Fatalf("GetQuickReplyMessages = %+v err=%v", msgs, err)
}
if _, err := svc.RenameQuickReplyShortcut(ctx, userID, shortcutID, "renamed"); err != nil {
t.Fatalf("RenameQuickReplyShortcut: %v", err)
}
if _, err := svc.ReorderQuickReplies(ctx, userID, []int{shortcutID}); err != nil {
t.Fatalf("ReorderQuickReplies: %v", err)
}
deleteMutation, err := svc.DeleteQuickReplyMessages(ctx, userID, shortcutID, []int{msgs.Messages[0].ID})
if err != nil {
t.Fatalf("DeleteQuickReplyMessages: %v", err)
}
if deleteMutation.Kind != domain.QuickReplyMutationIDs || len(deleteMutation.MessageIDs) != 1 {
t.Fatalf("delete mutation = %+v", deleteMutation)
}
if _, err := svc.DeleteQuickReplyShortcut(ctx, userID, shortcutID); err != nil {
t.Fatalf("DeleteQuickReplyShortcut: %v", err)
}
if _, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil); !errors.Is(err, domain.ErrShortcutInvalid) {
t.Fatalf("GetQuickReplyMessages deleted err = %v, want ErrShortcutInvalid", err)
}
}

View file

@ -0,0 +1,116 @@
package account
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newLoginEmailService(t *testing.T) (*Service, *memory.UserStore) {
t.Helper()
users := memory.NewUserStore()
svc := NewService(memory.NewPasswordStore(), WithUsers(users))
return svc, users
}
func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User {
t.Helper()
u, err := users.Create(context.Background(), domain.User{Phone: phone, FirstName: "Test"})
if err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始
// 地址只在 LoginEmail 读路径可见。
func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010001")
if err := svc.SetLoginEmail(ctx, u.ID, "alice@example.com"); err != nil {
t.Fatalf("SetLoginEmail: %v", err)
}
settings, err := svc.GetPassword(ctx, u.ID)
if err != nil {
t.Fatalf("GetPassword: %v", err)
}
if got, want := settings.LoginEmailPattern, "a***e@example.com"; got != want {
t.Fatalf("LoginEmailPattern = %q, want %q", got, want)
}
if settings.LoginEmail != "alice@example.com" {
t.Fatalf("LoginEmail = %q, want raw address", settings.LoginEmail)
}
email, found, err := svc.LoginEmail(ctx, u.ID)
if err != nil || !found || email != "alice@example.com" {
t.Fatalf("LoginEmail = %q found=%v err=%v", email, found, err)
}
}
// TestLoginEmailByPhoneAndClear 验证按手机号读取/清除登录邮箱(sendCode 检测 + reset 用)。
func TestLoginEmailByPhoneAndClear(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
createUser(t, users, "15550010002")
if err := svc.SetLoginEmailByPhone(ctx, "+1 555 001 0002", "bob@mail.com"); err != nil {
t.Fatalf("SetLoginEmailByPhone: %v", err)
}
email, found, err := svc.LoginEmailByPhone(ctx, "15550010002")
if err != nil || !found || email != "bob@mail.com" {
t.Fatalf("LoginEmailByPhone = %q found=%v err=%v", email, found, err)
}
if err := svc.ClearLoginEmailByPhone(ctx, "15550010002"); err != nil {
t.Fatalf("ClearLoginEmailByPhone: %v", err)
}
if _, found, _ := svc.LoginEmailByPhone(ctx, "15550010002"); found {
t.Fatal("login email still present after clear")
}
}
// TestSetLoginEmailRejectsInvalid 空/无 @ 的邮箱被拒。
func TestSetLoginEmailRejectsInvalid(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010003")
for _, bad := range []string{"", " ", "not-an-email"} {
if err := svc.SetLoginEmail(ctx, u.ID, bad); !errors.Is(err, domain.ErrEmailInvalid) {
t.Fatalf("SetLoginEmail(%q) err = %v, want ErrEmailInvalid", bad, err)
}
}
}
// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱
// 不得把恢复邮箱掩码写进 login_email_pattern(历史 bug)。
func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010004")
// 设置 2FA 恢复邮箱(email-only 路径即可触发历史 bug 的写入点)。
if err := svc.UpdatePasswordSettings(ctx, u.ID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{
Email: "recovery@secret.com",
HasEmail: true,
}); err != nil {
t.Fatalf("UpdatePasswordSettings: %v", err)
}
settings, err := svc.GetPassword(ctx, u.ID)
if err != nil {
t.Fatalf("GetPassword: %v", err)
}
if settings.LoginEmailPattern != "" {
t.Fatalf("LoginEmailPattern = %q, want empty (recovery email must not leak into login email)", settings.LoginEmailPattern)
}
if !settings.HasRecovery {
t.Fatal("HasRecovery = false, want true after setting recovery email")
}
}

View file

@ -4,7 +4,6 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"fmt"
"strings"
"time"
@ -19,22 +18,17 @@ const (
passwordResetRetry = 24 * time.Hour
)
// EmailUnconfirmedError reports the dev recovery-code length expected by TDesktop.
type EmailUnconfirmedError struct {
Length int
}
func (e EmailUnconfirmedError) Error() string {
if e.Length <= 0 {
return "email unconfirmed"
}
return fmt.Sprintf("email unconfirmed: %d", e.Length)
}
// Service 提供账号安全配置查询。
type Service struct {
passwords store.PasswordStore
reactions store.AccountReactionSettingsStore
passwords store.PasswordStore
reactions store.AccountReactionSettingsStore
settings store.AccountSettingsStore
notify store.NotifySettingsStore
stickers store.StickerCollectionStore
savedMusic store.SavedMusicStore
business store.BusinessAutomationStore
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
users store.UserStore
}
// ServiceOption 调整 account 服务依赖。
@ -47,6 +41,48 @@ func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceO
}
}
// WithAccountSettings 注入账号级单例设置(全局隐私/TTL/敏感内容/注册通知)持久化。
func WithAccountSettings(settings store.AccountSettingsStore) ServiceOption {
return func(s *Service) {
s.settings = settings
}
}
// WithNotifySettings 注入 per-scope 通知设置持久化。
func WithNotifySettings(notify store.NotifySettingsStore) ServiceOption {
return func(s *Service) {
s.notify = notify
}
}
// WithStickerCollections 注入个人贴纸/GIF 集合持久化(faved/recent/gif)。
func WithStickerCollections(stickers store.StickerCollectionStore) ServiceOption {
return func(s *Service) {
s.stickers = stickers
}
}
// WithSavedMusic 注入账号级 profile music 列表持久化。
func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption {
return func(s *Service) {
s.savedMusic = savedMusic
}
}
// WithBusinessAutomation 注入账号级 Business Profile/Quick Replies/Chat Links 持久化。
func WithBusinessAutomation(business store.BusinessAutomationStore) ServiceOption {
return func(s *Service) {
s.business = business
}
}
// WithUsers 注入用户读存储,供登录邮箱的 phone→user 解析使用。
func WithUsers(users store.UserStore) ServiceOption {
return func(s *Service) {
s.users = users
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{passwords: passwords}
@ -56,6 +92,42 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
return s
}
// SaveMusic adds, removes, or reorders a song in the current user's profile music list.
func (s *Service) SaveMusic(ctx context.Context, userID int64, req domain.SaveMusicRequest) (bool, error) {
if userID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() {
return false, domain.ErrDocumentInvalid
}
if s == nil || s.savedMusic == nil {
return true, nil
}
req.UserID = userID
return true, s.savedMusic.SaveMusic(ctx, req)
}
// ListSavedMusicIDs returns the full ordered id list for account.getSavedMusicIds.
func (s *Service) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
if s == nil || s.savedMusic == nil || userID == 0 {
return nil, nil
}
return s.savedMusic.ListSavedMusicIDs(ctx, userID, limit)
}
// ListSavedMusic returns an ordered saved/profile music page.
func (s *Service) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) {
if s == nil || s.savedMusic == nil || userID == 0 {
return domain.SavedMusicList{UserID: userID}, nil
}
return s.savedMusic.ListSavedMusic(ctx, userID, offset, limit)
}
// GetSavedMusicByIDs refreshes file references for songs still present in the user's list.
func (s *Service) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) {
if s == nil || s.savedMusic == nil || userID == 0 || len(ids) == 0 {
return domain.SavedMusicList{UserID: userID}, nil
}
return s.savedMusic.GetSavedMusicByIDs(ctx, userID, ids)
}
// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。
func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) {
if s == nil || s.passwords == nil || userID == 0 {
@ -110,6 +182,9 @@ func normalizePasswordSettings(settings domain.PasswordSettings) domain.Password
if settings.RecoveryEmail != "" {
settings.HasRecovery = true
}
// login_email_pattern 始终从已确认的登录邮箱派生,与 2FA 恢复邮箱 RecoveryEmail
// 解耦(历史实现曾把恢复邮箱掩码误写进此字段,导致客户端把恢复邮箱当成登录邮箱显示)。
settings.LoginEmailPattern = emailPattern(settings.LoginEmail)
return settings
}
@ -197,7 +272,6 @@ func (s *Service) UpdatePasswordSettings(ctx context.Context, userID int64, chec
}
settings.RecoveryEmail = email
settings.HasRecovery = email != ""
settings.LoginEmailPattern = emailPattern(email)
settings.EmailUnconfirmedPattern = ""
}
settings.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom)
@ -378,15 +452,98 @@ func randomInt64() (int64, error) {
}
func emailPattern(email string) string {
if email == "" {
return ""
return domain.MaskEmail(email)
}
// validLoginEmail 是登录邮箱的最小校验:非空且含 '@'。开发环境不做更严格的 RFC 校验。
func validLoginEmail(email string) bool {
return email != "" && strings.Contains(email, "@")
}
// SetLoginEmail 为已登录用户写入登录邮箱(authed 的 emailVerifyPurposeLoginChange)。
// 账号无 2FA 也可设置:account_passwords 行可在 has_password=false 下仅承载登录邮箱。
func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error {
if s == nil || s.passwords == nil || userID == 0 {
return domain.ErrEmailInvalid
}
at := strings.Index(email, "@")
if at <= 1 {
return email
email = strings.TrimSpace(email)
if !validLoginEmail(email) {
return domain.ErrEmailInvalid
}
name := email[:at]
return name[:1] + "***" + name[len(name)-1:] + email[at:]
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
if err != nil {
return err
}
settings.LoginEmail = email
settings.LoginEmailPattern = emailPattern(email)
return s.passwords.Save(ctx, userID, settings)
}
// SetLoginEmailByPhone 为某手机号对应的账号写入登录邮箱(登录流程中的
// emailVerifyPurposeLoginSetup,此时尚未鉴权,只能凭 phone 定位用户)。
func (s *Service) SetLoginEmailByPhone(ctx context.Context, phone, email string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return domain.ErrEmailInvalid
}
return s.SetLoginEmail(ctx, userID, email)
}
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
if s == nil || s.passwords == nil || userID == 0 {
return "", false, nil
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
if err != nil {
return "", false, err
}
if !found || settings.LoginEmail == "" {
return "", false, nil
}
return settings.LoginEmail, true, nil
}
// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、
// login-setup 回显、reset 回显使用)。
func (s *Service) LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error) {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil || !found {
return "", false, err
}
return s.LoginEmail(ctx, userID)
}
// ClearLoginEmailByPhone 清除某手机号账号的登录邮箱(auth.resetLoginEmail)。
func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return nil
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
if err != nil || !found {
return err
}
settings.LoginEmail = ""
settings.LoginEmailPattern = ""
return s.passwords.Save(ctx, userID, settings)
}
func (s *Service) userIDByPhone(ctx context.Context, phone string) (int64, bool, error) {
if s == nil || s.users == nil {
return 0, false, nil
}
u, found, err := s.users.ByPhone(ctx, domain.NormalizePhone(phone))
if err != nil || !found {
return 0, false, err
}
return u.ID, true, nil
}
// GetReactionSettings returns account-level reaction preferences.
@ -420,7 +577,7 @@ func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction
if err != nil {
return domain.AccountReactionSettings{}, err
}
if reaction.Type == "" || reaction.Emoticon == "" {
if !reaction.Valid() {
reaction = domain.DefaultAccountReactionSettings().DefaultReaction
}
settings.DefaultReaction = reaction
@ -445,10 +602,157 @@ func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settin
return settings, s.reactions.SaveReactionSettings(ctx, userID, settings)
}
// GetAccountSettings 返回账号级单例设置(未持久化时回落默认)。
func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
if s == nil || s.settings == nil || userID == 0 {
return domain.DefaultAccountSettings(), nil
}
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
if !found {
return domain.DefaultAccountSettings(), nil
}
settings.AccountTTLDays = settings.NormalizedTTLDays()
return settings, nil
}
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
if privacy.NoncontactPeersPaidStars < 0 {
privacy.NoncontactPeersPaidStars = 0
}
settings.GlobalPrivacy = privacy
return s.saveAccountSettings(ctx, userID, settings)
}
// SetAccountTTL 持久化账号自毁期限(钳制 >0)。
func (s *Service) SetAccountTTL(ctx context.Context, userID int64, days int) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.AccountTTLDays = days
settings.AccountTTLDays = settings.NormalizedTTLDays()
return s.saveAccountSettings(ctx, userID, settings)
}
// SetSensitiveContent 持久化敏感内容查看开关。
func (s *Service) SetSensitiveContent(ctx context.Context, userID int64, enabled bool) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.SensitiveContentEnabled = enabled
return s.saveAccountSettings(ctx, userID, settings)
}
// SetContactSignUpSilent 持久化“联系人注册时是否静音通知”。
func (s *Service) SetContactSignUpSilent(ctx context.Context, userID int64, silent bool) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.ContactSignUpSilent = silent
return s.saveAccountSettings(ctx, userID, settings)
}
func (s *Service) saveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) (domain.AccountSettings, error) {
settings.AccountTTLDays = settings.NormalizedTTLDays()
if settings.GlobalPrivacy.NoncontactPeersPaidStars < 0 {
settings.GlobalPrivacy.NoncontactPeersPaidStars = 0
}
if s == nil || s.settings == nil || userID == 0 {
return settings, nil
}
return settings, s.settings.SaveAccountSettings(ctx, userID, settings)
}
// GetNotifySettings 返回某作用域的通知设置(未配置返回零值=继承默认)。
func (s *Service) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return domain.PeerNotifySettings{}, nil
}
settings, _, err := s.notify.GetNotifySettings(ctx, ownerUserID, scope)
if err != nil {
return domain.PeerNotifySettings{}, err
}
return settings, nil
}
// SaveNotifySettings 持久化某作用域的通知设置。
func (s *Service) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil
}
return s.notify.SaveNotifySettings(ctx, ownerUserID, scope, settings)
}
// ResetNotifySettings 清空该用户全部作用域的通知设置(恢复默认)。
func (s *Service) ResetNotifySettings(ctx context.Context, ownerUserID int64) error {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil
}
return s.notify.ResetNotifySettings(ctx, ownerUserID)
}
// PeerNotifySettings 批量取一组 peer 的整-peer 通知设置(dialog 列表投影)。
func (s *Service) PeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 || len(peers) == 0 {
return nil, nil
}
return s.notify.GetPeerNotifySettings(ctx, ownerUserID, peers)
}
// AllPeerNotifySettings 一次取该用户全部整-peer 通知设置(per-user notify 缓存的加载源)。
func (s *Service) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil, nil
}
return s.notify.AllPeerNotifySettings(ctx, ownerUserID)
}
// ListNotifyExceptions 列出该用户全部 per-peer 非默认通知设置(getNotifyExceptions)。
func (s *Service) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil, nil
}
return s.notify.ListNotifyExceptions(ctx, ownerUserID)
}
// SaveStickerCollectionItem 收藏/最近/GIF 集合的加入或移除(最新置顶、按类别上界截断)。
func (s *Service) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now int) error {
if s == nil || s.stickers == nil || userID == 0 {
return nil
}
return s.stickers.SaveStickerCollectionItem(ctx, userID, kind, documentID, unsave, now, domain.MaxStickerCollectionItems(kind))
}
// ListStickerCollection 取某类个人贴纸集合(最新在前)。
func (s *Service) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) {
if s == nil || s.stickers == nil || userID == 0 {
return nil, nil
}
return s.stickers.ListStickerCollection(ctx, userID, kind, limit)
}
// ClearStickerCollection 清空某类个人贴纸集合。
func (s *Service) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error {
if s == nil || s.stickers == nil || userID == 0 {
return nil
}
return s.stickers.ClearStickerCollection(ctx, userID, kind)
}
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
defaults := domain.DefaultAccountReactionSettings()
settings.Notify = normalizeNotifySettings(settings.Notify)
if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" {
if !settings.DefaultReaction.Valid() {
settings.DefaultReaction = defaults.DefaultReaction
}
settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy)

View file

@ -3,11 +3,14 @@ package account
import (
"bytes"
"context"
"crypto/sha512"
"errors"
"math/big"
"testing"
"time"
"golang.org/x/crypto/pbkdf2"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -183,3 +186,19 @@ func clientPasswordCheck(t *testing.T, settings domain.PasswordSettings, passwor
)
return domain.PasswordCheck{SRPID: settings.SRPID, A: aForHash, M1: m1}
}
// passwordDigest 与 verifierForPassword 是客户端侧(明文口令 → verifier)的模拟助手,
// 服务端从不执行明文口令路径,仅供这里的客户端 SRP helper 构造测试输入。
func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte {
hash1 := hashBytes(algo.Salt1, password, algo.Salt1)
hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2)
hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New)
return hashBytes(algo.Salt2, hash3, algo.Salt2)
}
func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte {
p := new(big.Int).SetBytes(algo.P)
g := big.NewInt(int64(algo.G))
x := new(big.Int).SetBytes(passwordDigest(algo, password))
return padToHash(new(big.Int).Exp(g, x, p).Bytes())
}

View file

@ -4,12 +4,9 @@ import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"math/big"
"golang.org/x/crypto/pbkdf2"
"telesrv/internal/domain"
)
@ -160,20 +157,6 @@ func hashBytes(parts ...[]byte) []byte {
return h.Sum(nil)
}
func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte {
hash1 := hashBytes(algo.Salt1, password, algo.Salt1)
hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2)
hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New)
return hashBytes(algo.Salt2, hash3, algo.Salt2)
}
func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte {
p := new(big.Int).SetBytes(algo.P)
g := big.NewInt(int64(algo.G))
x := new(big.Int).SetBytes(passwordDigest(algo, password))
return padToHash(new(big.Int).Exp(g, x, p).Bytes())
}
func padToHash(in []byte) []byte {
if len(in) >= passwordHashSize {
return append([]byte(nil), in[len(in)-passwordHashSize:]...)

View file

@ -0,0 +1,102 @@
package auth
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestSignInWithEmailCompletesLogin 验证带 email_verification 的登录:注册账号→登出→
// 重新 sendCode→用任意邮箱验证码经 SignInWithEmail 完成登录。
func TestSignInWithEmailCompletesLogin(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
var key [8]byte
key[0] = 0x42
hash, err := svc.SendCode(ctx, "+15550009001")
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "Email", "Login")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if err := svc.LogOut(ctx, key); err != nil {
t.Fatalf("LogOut: %v", err)
}
hash, err = svc.SendCode(ctx, "+15550009001")
if err != nil {
t.Fatalf("SendCode signin: %v", err)
}
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "anything-goes")
if err != nil {
t.Fatalf("SignInWithEmail: %v", err)
}
if needSignUp || got.ID != u.ID {
t.Fatalf("SignInWithEmail user=%+v needSignUp=%v, want existing user %d", got, needSignUp, u.ID)
}
bound, found, err := svc.UserID(ctx, key)
if err != nil || !found || bound != u.ID {
t.Fatalf("UserID after email signin = %d found=%v err=%v, want %d", bound, found, err, u.ID)
}
}
// TestSignInWithEmailRejectsEmptyCode 空邮箱验证码必须被拒(即使开发环境码任意,也不能空)。
func TestSignInWithEmailRejectsEmptyCode(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345")
hash, err := svc.SendCode(ctx, "+15550009002")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009002", hash, " "); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("SignInWithEmail empty code err = %v, want ErrCodeInvalid", err)
}
}
// TestSignInWithEmailStillHonorsTwoFactor 登录邮箱与 2FA 正交:即使走邮箱验证码,
// 开启了两步验证的账号仍停在 SESSION_PASSWORD_NEEDED,不能绕过密码。
func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) {
ctx := context.Background()
passwords := memory.NewPasswordStore()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords))
var key [8]byte
key[0] = 0x43
hash, err := svc.SendCode(ctx, "+15550009003")
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "Two", "Factor")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if err := svc.LogOut(ctx, key); err != nil {
t.Fatalf("LogOut: %v", err)
}
if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil {
t.Fatalf("save password settings: %v", err)
}
hash, err = svc.SendCode(ctx, "+15550009003")
if err != nil {
t.Fatalf("SendCode signin: %v", err)
}
got, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "any-email-code")
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
t.Fatalf("SignInWithEmail err = %v, want ErrSessionPasswordNeeded", err)
}
if got.ID != u.ID {
t.Fatalf("SignInWithEmail user = %+v, want pending 2FA user %d", got, u.ID)
}
if bound, found, err := svc.UserID(ctx, key); err != nil || found || bound != 0 {
t.Fatalf("UserID after email signin with 2FA = %d found=%v err=%v, want not-found", bound, found, err)
}
}

View file

@ -0,0 +1,51 @@
package auth
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestSignUpPremiumGrant 验证新注册账号默认赠送 3 个月会员(WithPremiumGrant),
// 以及 0 = 关闭赠送分支。
func TestSignUpPremiumGrant(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(3))
hash, err := svc.SendCode(ctx, "+15550004401")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004401", hash, "Prem", "User")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
wantMin := time.Now().AddDate(0, 3, 0).Add(-time.Minute).Unix()
wantMax := time.Now().AddDate(0, 3, 0).Add(time.Minute).Unix()
if int64(u.PremiumUntil) < wantMin || int64(u.PremiumUntil) > wantMax {
t.Fatalf("PremiumUntil = %d, want ~now+3mo [%d,%d]", u.PremiumUntil, wantMin, wantMax)
}
if !u.PremiumActiveAt(time.Now().Unix()) {
t.Fatal("new user should be premium active")
}
}
func TestSignUpPremiumGrantDisabled(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(0))
hash, err := svc.SendCode(ctx, "+15550004402")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004402", hash, "Free", "User")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if u.PremiumUntil != 0 {
t.Fatalf("PremiumUntil = %d, want 0 (grant disabled)", u.PremiumUntil)
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/aes"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"errors"
@ -25,8 +26,39 @@ var (
ErrCodeExpired = errors.New("phone code expired or not found")
ErrCodeInvalid = errors.New("phone code invalid")
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
// ErrPhoneNumberInvalid 表示手机号为空或非纯数字/长度越界。
// 0090 把 users.phone 唯一约束改为忽略空串的部分索引(bot 行 phone=''),
// 因此 phone 校验必须前移到 auth 入口,否则 sendCode/signUp 可无限铸造
// phone='' 的幽灵人类账号(且因 ByPhone('') 短路永远无法再登录)。
ErrPhoneNumberInvalid = errors.New("phone number invalid")
// ErrSystemUserLoginForbidden 表示内置系统账号被尝试绑定为普通业务会话。
ErrSystemUserLoginForbidden = errors.New("system user login forbidden")
)
// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。
// 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号),
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。
func validPhone(phone string) bool {
if len(phone) < 5 || len(phone) > 32 {
return false
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
}
func systemUserLoginForbidden(u domain.User) bool {
return domain.IsSystemUserID(u.ID)
}
func systemLoginPhoneForbidden(phone string) bool {
_, ok := domain.SystemUserByPhone(phone)
return ok
}
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
type Service struct {
users store.UserStore
@ -37,8 +69,16 @@ type Service struct {
passwords store.PasswordStore
messages store.MessageStore
dialogs store.DialogStore
bots store.BotStore
fixedCode string
codeTTL time.Duration
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
premiumGrantMonths int
}
type authorizationRevoker interface {
RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
// Option 调整登录服务的可选依赖。
@ -59,6 +99,21 @@ func WithPasswords(passwords store.PasswordStore) Option {
}
}
// WithBotLogin 启用 auth.importBotAuthorization 的 bot token 登录。
func WithBotLogin(bots store.BotStore) Option {
return func(s *Service) {
s.bots = bots
}
}
// WithPremiumGrant 让新注册账号默认获得 months 个月会员(0 = 关闭赠送)。
// 存量账号的同等赠送由迁移 0094 一次性 backfill。
func WithPremiumGrant(months int) Option {
return func(s *Service) {
s.premiumGrantMonths = months
}
}
// 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}
@ -84,6 +139,13 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding
}
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
//
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey):
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析,
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动
// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。
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
@ -92,13 +154,22 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
if err != nil || !found {
return [8]byte{}, found, err
}
if binding.ExpiresAt <= int(time.Now().Unix()) {
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
return [8]byte{}, false, nil
}
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
return permID, true, nil
}
// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
if s == nil || s.auths == nil {
return false
}
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
return err == nil && found
}
// 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
@ -107,16 +178,56 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
if err != nil || !found {
return 0, found, err
}
if a.PasswordPending {
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
return 0, false, nil
}
if domain.IsSystemUserID(a.UserID) {
_ = s.auths.Delete(ctx, authKeyID)
return 0, false, nil
}
return a.UserID, true, nil
}
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
func (s *Service) PendingPasswordUserID(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 || !a.PasswordPending {
return 0, false, err
}
if domain.IsSystemUserID(a.UserID) {
_ = s.auths.Delete(ctx, authKeyID)
return 0, false, nil
}
return a.UserID, true, nil
}
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
if s == nil || s.auths == nil {
return nil
}
return s.auths.MarkPasswordPassed(ctx, authKeyID)
}
// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
phone = normalizePhone(phone)
if !validPhone(phone) {
return "", ErrPhoneNumberInvalid
}
if systemLoginPhoneForbidden(phone) {
return "", ErrSystemUserLoginForbidden
}
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 {
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: phone, Code: s.fixedCode}, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
return hash, nil
@ -159,6 +270,9 @@ func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) e
// 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)
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return domain.User{}, domain.Message{}, false, err
@ -177,14 +291,61 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
if !found {
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
}
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
}
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开发环境接受任意非空邮箱码
// (与短信固定码同口径,"随意输入");仍校验 phone_code_hash 有效、手机号匹配,并与短信
// 登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号同样会停在 SESSION_PASSWORD_NEEDED。
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
phone = normalizePhone(phone)
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
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 {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
if strings.TrimSpace(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
}
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
}
// finishSignIn 是短信/邮箱两条登录路径在「验证码已通过、用户已存在」之后的共用收尾:
// 处理 2FA password_pending 绑定、写登录消息、消费验证码。
func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User, phoneCodeHash, loginCode string) (domain.User, domain.Message, bool, error) {
if systemUserLoginForbidden(existing) {
_ = s.codes.Del(ctx, phoneCodeHash)
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
// 开启两步验证的账号:把授权标记为 password_pending 再写入,业务鉴权据此拒绝该 auth_key,
// 直到 auth.checkPassword 通过。绝不能先以完全授权写入再返回 SESSION_PASSWORD_NEEDED,
// 否则客户端忽略该错误即可直接调用业务 RPC 绕过两步验证。
passwordNeeded := s.passwordNeeded(ctx, existing.ID)
auth.PasswordPending = passwordNeeded
if err := s.bind(ctx, auth, existing.ID); err != nil {
return domain.User{}, domain.Message{}, false, err
}
if s.passwordNeeded(ctx, existing.ID) {
if passwordNeeded {
_ = s.codes.Del(ctx, phoneCodeHash)
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
}
loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code)
loginMessage, err := s.recordLoginMessage(ctx, existing.ID, loginCode)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
@ -196,6 +357,12 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
// 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)
if !validPhone(phone) {
return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid
}
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, ErrSystemUserLoginForbidden
}
firstName = strings.TrimSpace(firstName)
lastName = strings.TrimSpace(lastName)
if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 {
@ -216,12 +383,18 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
if err != nil {
return domain.User{}, domain.Message{}, err
}
u, err := s.users.Create(ctx, domain.User{
newUser := domain.User{
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
})
}
// 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量
// 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。
if s.premiumGrantMonths > 0 {
newUser.PremiumUntil = int(time.Now().AddDate(0, s.premiumGrantMonths, 0).Unix())
}
u, err := s.users.Create(ctx, newUser)
if err != nil {
return domain.User{}, domain.Message{}, err
}
@ -236,11 +409,115 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return u, loginMessage, nil
}
// SignInBot 处理 auth.importBotAuthorization:校验 bot token 并把当前 auth_key
// 绑定到 bot 账号。token 校验必须先于 bind(bind 即授权生效);bot 无 2FA,
// PasswordPending 恒 false;不写登录消息、不推 signIn 通知(手机登录语义)。
// 任何校验失败统一返回 domain.ErrBotTokenInvalid,不区分原因避免泄漏存在性。
func (s *Service) SignInBot(ctx context.Context, auth domain.Authorization, token string) (domain.User, error) {
if s == nil || s.bots == nil || s.users == nil {
return domain.User{}, domain.ErrBotTokenInvalid
}
botUserID, secret, ok := domain.ParseBotToken(strings.TrimSpace(token))
if !ok {
return domain.User{}, domain.ErrBotTokenInvalid
}
profile, found, err := s.bots.GetBot(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
// 空 secret(内置 BotFather)永不可登录;比较走常数时间。
if !found || profile.TokenSecret == "" ||
subtle.ConstantTimeCompare([]byte(profile.TokenSecret), []byte(secret)) != 1 {
return domain.User{}, domain.ErrBotTokenInvalid
}
u, found, err := s.users.ByID(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
if !found || !u.Bot {
return domain.User{}, domain.ErrBotTokenInvalid
}
if systemUserLoginForbidden(u) {
return domain.User{}, domain.ErrBotTokenInvalid
}
auth.PasswordPending = false
if err := s.bind(ctx, auth, u.ID); err != nil {
return domain.User{}, err
}
// check-bind-recheck:SignInBot 的「校验 secret → bind」非原子,并发 /revoke
// 可能在两步之间换 secret 并删除已有 authorization。此处 bind 写入的新行不会被
// 那次删除覆盖(删除发生在 bind 之前),会逃过 session 撤销。bind 后复核 secret:
// 若已被换掉,撤销刚写入的授权并拒登,闭合竞态窗口。
if again, found, err := s.bots.GetBot(ctx, botUserID); err != nil {
_ = s.auths.Delete(ctx, auth.AuthKeyID)
return domain.User{}, err
} else if !found || again.TokenSecret == "" ||
subtle.ConstantTimeCompare([]byte(again.TokenSecret), []byte(secret)) != 1 {
_ = s.auths.Delete(ctx, auth.AuthKeyID)
return domain.User{}, domain.ErrBotTokenInvalid
}
return u, nil
}
// BindVerifiedLogin 把当前 auth_key 绑定到一个已由外部强因子(如 passkey)验证过身份的
// 用户,直接完成授权。passkey 是独立强因子,不再叠加 2FA password(PasswordPending=false),
// 与官方"passkey 登录跳过密码步骤"一致。校验已发生在调用方(passkey 断言验证),此处只负责绑定。
func (s *Service) BindVerifiedLogin(ctx context.Context, auth domain.Authorization, userID int64) (domain.User, error) {
if s == nil || s.users == nil || userID == 0 {
return domain.User{}, domain.ErrPasskeyInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrPasskeyNotFound
}
if systemUserLoginForbidden(u) {
return domain.User{}, ErrSystemUserLoginForbidden
}
auth.PasswordPending = false
if err := s.bind(ctx, auth, userID); err != nil {
return domain.User{}, err
}
return u, nil
}
// AcceptLoginToken 把 QR 登录请求方的 auth_key 绑定到扫码确认的 user。
func (s *Service) AcceptLoginToken(ctx context.Context, auth domain.Authorization, userID int64) (domain.Authorization, error) {
if s == nil || s.auths == nil || userID == 0 || auth.AuthKeyID == ([8]byte{}) {
return domain.Authorization{}, fmt.Errorf("accept login token: invalid authorization")
}
if domain.IsSystemUserID(userID) {
return domain.Authorization{}, ErrSystemUserLoginForbidden
}
auth.PasswordPending = false
if err := s.bind(ctx, auth, userID); err != nil {
return domain.Authorization{}, err
}
bound, found, err := s.auths.ByAuthKey(ctx, auth.AuthKeyID)
if err != nil {
return domain.Authorization{}, err
}
if found {
return bound, nil
}
auth.UserID = userID
return auth, nil
}
// LogOut 解绑当前 auth_key 的授权。
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
return s.auths.Delete(ctx, authKeyID)
}
func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) {
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) {
return domain.Authorization{}, false, nil
}
return s.auths.ByAuthKey(ctx, authKeyID)
}
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
if s == nil || s.auths == nil || userID == 0 {
return nil, nil
@ -252,14 +529,78 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d
if s == nil || s.auths == nil || userID == 0 {
return domain.Authorization{}, false, nil
}
return s.auths.DeleteByHash(ctx, userID, hash)
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByHash(ctx, userID, hash)
}
target, found, err := s.authorizationByHash(ctx, userID, hash)
if err != nil || !found {
return target, found, err
}
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
return target, true, err
}
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
if err != nil || !found {
return deleted, found, err
}
return deleted, true, nil
}
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
if s == nil || s.auths == nil || userID == 0 {
return nil, nil
}
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
}
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
for _, a := range targets {
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
return nil, err
}
}
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
return deleted, nil
}
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.Delete(ctx, authKeyID)
}
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return domain.Authorization{}, false, err
}
for _, a := range items {
if a.Hash == hash {
return a, true, nil
}
}
return domain.Authorization{}, false, nil
}
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]domain.Authorization, 0, len(items))
for _, a := range items {
if a.AuthKeyID != keepAuthKeyID {
out = append(out, a)
}
}
return out, nil
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
@ -301,11 +642,10 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str
if err != nil {
return domain.Message{}, err
}
if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{
if err := s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{
Peer: msg.Peer,
TopMessage: msg.ID,
TopMessageDate: msg.Date,
UnreadCount: 1,
}); err != nil {
return domain.Message{}, err
}
@ -406,17 +746,7 @@ func authKeyIDInt64(id [8]byte) int64 {
}
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()
return domain.NormalizePhone(phone)
}
func randomHex(n int) (string, error) {

View file

@ -95,6 +95,58 @@ func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
}
}
func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()
authz := memory.NewAuthorizationStore()
permKey := testAuthKey(0x21)
tempKey := testAuthKey(0x65)
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, tempBindings, "12345")
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: 1000000001}); err != nil {
t.Fatalf("bind authorization: %v", err)
}
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(-time.Minute).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 authorized perm %x", got, ok, permKey.ID)
}
}
func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()
permKey := testAuthKey(0x31)
tempKey := testAuthKey(0x75)
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.Minute).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 != ([8]byte{}) {
t.Fatalf("resolved = %x ok=%v, want expired unresolved", got, ok)
}
}
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -126,6 +178,73 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
}
}
func TestSystemUserPhoneCannotLoginOrSignUp(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345")
phone := domain.OfficialSystemUser().Phone
if _, err := svc.SendCode(ctx, phone); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("SendCode official system phone err = %v, want ErrSystemUserLoginForbidden", err)
}
if err := codes.Set(ctx, "system-signin", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
t.Fatalf("seed sign-in code: %v", err)
}
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, "system-signin", "12345"); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("SignIn official system phone err = %v, want ErrSystemUserLoginForbidden", err)
}
if err := codes.Set(ctx, "system-email", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
t.Fatalf("seed email code: %v", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, "system-email", "anything"); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("SignInWithEmail official system phone err = %v, want ErrSystemUserLoginForbidden", err)
}
if err := codes.Set(ctx, "system-signup", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
t.Fatalf("seed sign-up code: %v", err)
}
if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, "system-signup", "System", "User"); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("SignUp official system phone err = %v, want ErrSystemUserLoginForbidden", err)
}
}
func TestSystemUserAuthorizationIsRejectedAndRevoked(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
authKeyID := [8]byte{0x71}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: domain.OfficialSystemUserID}); err != nil {
t.Fatalf("bind system authorization: %v", err)
}
if got, found, err := svc.UserID(ctx, authKeyID); err != nil || found || got != 0 {
t.Fatalf("UserID(system auth) = %d found=%v err=%v, want not found", got, found, err)
}
if _, found, err := authz.ByAuthKey(ctx, authKeyID); err != nil || found {
t.Fatalf("system authorization after UserID found=%v err=%v, want deleted", found, err)
}
pendingAuthKeyID := [8]byte{0x72}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: domain.OfficialSystemUserID, PasswordPending: true}); err != nil {
t.Fatalf("bind pending system authorization: %v", err)
}
if got, pending, err := svc.PendingPasswordUserID(ctx, pendingAuthKeyID); err != nil || pending || got != 0 {
t.Fatalf("PendingPasswordUserID(system auth) = %d pending=%v err=%v, want not pending", got, pending, err)
}
if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found {
t.Fatalf("pending system authorization after lookup found=%v err=%v, want deleted", found, err)
}
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: [8]byte{0x73}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("BindVerifiedLogin official system user err = %v, want ErrSystemUserLoginForbidden", err)
}
if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: [8]byte{0x74}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("AcceptLoginToken official system user err = %v, want ErrSystemUserLoginForbidden", err)
}
}
func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
@ -205,6 +324,77 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
}
}
func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, nil, "12345")
key := [8]byte{0x31}
if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil {
t.Fatalf("save auth key: %v", err)
}
hash, err := svc.SendCode(ctx, "+15550007001")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550007001", hash, "One", "")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
items, err := authz.ListByUser(ctx, u.ID)
if err != nil || len(items) != 1 {
t.Fatalf("ListByUser = %d err=%v, want one authorization", len(items), err)
}
deleted, found, err := svc.ResetAuthorization(ctx, u.ID, items[0].Hash)
if err != nil || !found || deleted.AuthKeyID != key {
t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key)
}
if _, found, err := keys.Get(ctx, key); err != nil || found {
t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err)
}
if _, found, err := svc.UserID(ctx, key); err != nil || found {
t.Fatalf("user after reset found=%v err=%v, want missing", found, err)
}
}
func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
users := memory.NewUserStore()
svc := NewService(users, authz, memory.NewCodeStore(), keys, nil, "12345")
keep := [8]byte{0x41}
revoked := [8]byte{0x42}
for _, key := range [][8]byte{keep, revoked} {
if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil {
t.Fatalf("save auth key %x: %v", key, err)
}
}
hash, err := svc.SendCode(ctx, "+15550007002")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: keep}, "+15550007002", hash, "Two", "")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: revoked, UserID: u.ID}); err != nil {
t.Fatalf("bind revoked authorization: %v", err)
}
deleted, err := svc.ResetAuthorizations(ctx, u.ID, keep)
if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked {
t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err)
}
if _, found, err := keys.Get(ctx, revoked); err != nil || found {
t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err)
}
if _, found, err := keys.Get(ctx, keep); err != nil || !found {
t.Fatalf("kept auth key found=%v err=%v, want present", found, err)
}
}
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
@ -241,6 +431,63 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
}
}
func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(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))
phone := "+15550004312"
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil {
t.Fatalf("MarkRead first login message: %v", err)
} else if read.MaxID != first.ID || read.StillUnreadCount != 0 {
t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID)
}
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin second: %v", err)
}
_, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog := func(wantTop, wantRead, wantUnread int) {
t.Helper()
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want official dialog", list.Dialogs)
}
got := list.Dialogs[0]
if got.TopMessage != wantTop || got.ReadInboxMaxID != wantRead || got.UnreadCount != wantUnread {
t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread)
}
}
assertOfficialDialog(second.ID, first.ID, 1)
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin third: %v", err)
}
_, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog(third.ID, first.ID, 2)
}
func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
ctx := context.Background()
passwords := memory.NewPasswordStore()
@ -274,9 +521,23 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
if needSignUp || got.ID != u.ID {
t.Fatalf("SignIn user=%+v needSignUp=%v, want existing 2FA user", got, needSignUp)
}
// 两步验证未完成:业务鉴权(UserID)必须视为未登录,避免绕过 2FA。
bound, found, err := svc.UserID(ctx, key)
if err != nil || found || bound != 0 {
t.Fatalf("UserID after password-needed = %d found=%v err=%v, want not-found", bound, found, err)
}
// 但仍可定位待验证用户,供 auth.checkPassword 继续。
pendingUID, pending, err := svc.PendingPasswordUserID(ctx, key)
if err != nil || !pending || pendingUID != u.ID {
t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID)
}
// 两步验证通过后转为完全授权。
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
t.Fatalf("CompletePasswordSignIn: %v", err)
}
bound, found, err = svc.UserID(ctx, key)
if err != nil || !found || bound != u.ID {
t.Fatalf("UserID after password-needed = %d found=%v err=%v, want %d", bound, found, err, u.ID)
t.Fatalf("UserID after 2FA passed = %d found=%v err=%v, want %d", bound, found, err, u.ID)
}
}

View file

@ -0,0 +1,686 @@
package bots
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// BotFather 对话状态机:用户发给 BotFather 的每条私聊消息经 app/messages 的
// responder hook 进入 OnPrivateMessage;用户消息已先行入库,这里只负责生成并
// 写入 BotFather 的回复(完整 SendPrivateText 链路:双盒+事件+outbox 推送)。
const (
botFatherCmdNewBot = "newbot"
botFatherCmdToken = "token"
botFatherCmdRevoke = "revoke"
botFatherCmdSetName = "setname"
botFatherCmdSetDescription = "setdescription"
botFatherCmdSetAbout = "setabouttext"
botFatherCmdSetCommands = "setcommands"
botFatherCmdSetInline = "setinline"
botFatherCmdSetInlineGeo = "setinlinegeo"
botFatherCmdSetInlineFB = "setinlinefeedback"
botFatherCmdSetJoinGroups = "setjoingroups"
botFatherCmdSetPrivacy = "setprivacy"
botFatherStepName = "name"
botFatherStepUsername = "username"
botFatherStepChoose = "choose"
botFatherStepValue = "value"
botFatherDraftBotID = "bot_id"
botFatherDraftBotUsername = "bot_username"
)
const botFatherHelpText = `I can help you create and manage Telegram bots.
You can control me by sending these commands:
/newbot - create a new bot
/mybots - list your bots
/token - show a bot's token
/revoke - revoke a bot's token
/setname - change a bot's name
/setdescription - change a bot's description
/setabouttext - change a bot's about info
/setcommands - change a bot's command list
/setinline - toggle inline mode
/setinlinegeo - toggle inline location requests
/setinlinefeedback - change inline feedback settings
/setjoingroups - toggle whether a bot can join groups
/setprivacy - toggle a bot's group privacy mode
/cancel - cancel the current operation
/help - show this message`
// botReply 是 BotFather 的一条回复。
type botReply struct {
Text string
Entities []domain.MessageEntity
}
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
return s != nil && botUserID == domain.BotFatherUserID
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID {
return
}
userID := msg.From.ID
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
return
}
go s.respondAsBotFather(userID, msg.Body)
}
// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
func (s *Service) respondAsBotFather(userID int64, body string) {
mu := &s.replyLocks[uint64(userID)%replyLockStripes]
mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
reply := s.handleBotFather(ctx, userID, body)
if reply.Text == "" {
return
}
blocked := false
if s.blocker != nil {
if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil {
s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err))
} else {
blocked = b
}
}
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.BotFatherUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: reply.Entities,
Date: int(s.now().Unix()),
RecipientBlocked: blocked,
}); err != nil {
s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err))
}
}
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
func (s *Service) botReplyRandomID() int64 {
if v, err := randomInt64(); err == nil && v != 0 {
return v
}
v := s.now().UnixNano() + s.replySeq.Add(1)
if v == 0 {
v = 1
}
return v
}
// botFatherGlobalCommands 是任何状态下都优先按命令处理的全局命令。其余以 "/"
// 开头的文本(如 /empty、或粘贴的 "/start - Begin" 命令列表首行)在收值步骤里
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
var botFatherGlobalCommands = map[string]bool{
"start": true, "help": true, "cancel": true,
botFatherCmdNewBot: true, "mybots": true,
botFatherCmdToken: true, botFatherCmdRevoke: true,
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
text = strings.TrimSpace(text)
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
// 命令拦截:仅当不在收值步骤、或文本是已知全局命令时,才走命令分发。收值步骤
// 下的非全局 "/..." 文本(/empty、命令列表首行)必须当原始值透传给状态机。
if cmd, ok := parseBotCommand(text); ok {
inValueStep := found && state.Step == botFatherStepValue
if !inValueStep || botFatherGlobalCommands[cmd] {
return s.handleBotFatherCommand(ctx, userID, cmd)
}
}
if text == "" {
// 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示,
// 无状态保持沉默(避免对任意非文本消息刷屏)。
if !found {
return botReply{}
}
return s.stepPrompt(state)
}
if !found {
return botReply{Text: "I can only help you create and manage bots. Send /help for a list of commands."}
}
switch {
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
return s.handleNewBotName(ctx, state, text)
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
return s.handleNewBotUsername(ctx, state, text)
case state.Step == botFatherStepChoose:
return s.handleChooseBot(ctx, state, text)
case state.Step == botFatherStepValue:
return s.handleSetValue(ctx, state, text)
default:
// 不可达的脏状态:清掉重来,避免用户被卡死。
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: "Something went wrong, I forgot what we were doing. Send /help for a list of commands."}
}
}
// pickerCommands 是「先选 bot」的命令集(choose step 后按命令分流)。
var pickerPrompts = map[string]string{
botFatherCmdToken: "Choose a bot to generate a token for. Send the bot's username:",
botFatherCmdRevoke: "Choose a bot to revoke the token of. Send the bot's username:",
botFatherCmdSetName: "Choose a bot to change the name of. Send the bot's username:",
botFatherCmdSetDescription: "Choose a bot to change the description of. Send the bot's username:",
botFatherCmdSetAbout: "Choose a bot to change the about info of. Send the bot's username:",
botFatherCmdSetCommands: "Choose a bot to change the command list of. Send the bot's username:",
botFatherCmdSetInline: "Choose a bot to change inline mode for. Send the bot's username:",
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
}
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
func (s *Service) startBotPicker(ctx context.Context, userID int64, cmd string) botReply {
usernames, err := s.ownedBotUsernames(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if len(usernames) == 0 {
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
}
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: cmd,
Step: botFatherStepChoose,
}); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: pickerPrompts[cmd] + "\n\n@" + strings.Join(usernames, "\n@")}
}
// stepPrompt 返回当前对话步骤的引导文案(空输入兜底用)。
func (s *Service) stepPrompt(state domain.BotChatState) botReply {
switch {
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
return botReply{Text: "Please choose a name for your bot, or /cancel."}
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
return botReply{Text: "Please send a username for your bot. It must end in `bot`. Or /cancel."}
case state.Step == botFatherStepChoose:
return botReply{Text: "Please send the username of one of your bots, or /cancel."}
case state.Step == botFatherStepValue:
return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])}
default:
return botReply{Text: "Send /help for a list of commands."}
}
}
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
switch cmd {
case "start", "help":
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: botFatherHelpText}
case "cancel":
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if !found {
return botReply{Text: "No active command to cancel. I wasn't doing anything anyway."}
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
case botFatherCmdNewBot:
count, err := s.bots.CountBotsByOwner(ctx, userID)
if err != nil {
s.log.Error("botfather: count bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if count >= domain.MaxBotsPerOwner {
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
}
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: botFatherCmdNewBot,
Step: botFatherStepName,
}); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."}
case "mybots":
usernames, err := s.ownedBotUsernames(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if len(usernames) == 0 {
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
}
return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")}
case botFatherCmdToken, botFatherCmdRevoke,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
return s.startBotPicker(ctx, userID, cmd)
case botFatherCmdSetInlineFB:
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: "Inline feedback settings are not supported yet. Use /setinline to enable or disable inline mode."}
default:
return botReply{Text: "Unrecognized command. Say what? Send /help for a list of commands."}
}
}
// valuePrompts 是选中 bot 后、value step 的收值提示(按命令)。
func valuePrompt(cmd, username string) string {
switch cmd {
case botFatherCmdSetName:
return fmt.Sprintf("OK. Send me the new name for @%s.", username)
case botFatherCmdSetDescription:
return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username)
case botFatherCmdSetAbout:
return fmt.Sprintf("OK. Send me the new about text for @%s. People will see this text on the bot's profile page and it will be sent together with a link to your bot when they share it with someone.", username)
case botFatherCmdSetCommands:
return fmt.Sprintf("OK. Send me a list of commands for @%s. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to clear the list.", username)
case botFatherCmdSetInline:
return fmt.Sprintf("This will enable inline queries for @%s. Send me the placeholder text people will see after typing the bot username, or send /empty to disable inline mode.", username)
case botFatherCmdSetInlineGeo:
return fmt.Sprintf("Send 'enable' to allow @%s to receive location in inline queries, or 'disable' to turn it off.", username)
case botFatherCmdSetJoinGroups:
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
case botFatherCmdSetPrivacy:
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
default:
return "Send the new value, or /cancel."
}
}
func (s *Service) handleNewBotName(ctx context.Context, state domain.BotChatState, name string) botReply {
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
return botReply{Text: fmt.Sprintf("Sorry, the bot name must be 1-%d characters long. Please choose a different name.", domain.MaxBotNameLength)}
}
state.Step = botFatherStepUsername
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft["name"] = name
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot."}
}
func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChatState, username string) botReply {
u, token, err := s.CreateBot(ctx, state.UserID, state.Draft["name"], username)
switch {
case errors.Is(err, domain.ErrBotUsernameInvalid):
return botReply{Text: "Sorry, this username is invalid. A bot username must be 5-32 characters long, start with a letter, contain only Latin letters, digits and underscores, and end in 'bot' (e.g. tetris_bot)."}
case errors.Is(err, domain.ErrUsernameOccupied):
return botReply{Text: "Sorry, this username is already taken. Please try something different."}
case errors.Is(err, domain.ErrBotsTooMany):
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID)
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
case errors.Is(err, domain.ErrBotNameInvalid):
// name 步已校验,这里只可能是脏状态;重新走 name 步。
state.Step = botFatherStepName
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Please choose a name for your bot first."}
case err != nil:
s.log.Error("botfather: create bot", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
}
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at telesrv.net/%s.\n\nUse this token to access the HTTP API:\n", u.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
}
func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState, text string) botReply {
username := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(text, "@")))
profiles, err := s.ownedBots(ctx, state.UserID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
var chosen *domain.User
for i := range profiles {
if strings.ToLower(profiles[i].user.Username) == username {
chosen = &profiles[i].user
break
}
}
if chosen == nil {
return botReply{Text: "I don't see that bot among yours. Send the username of one of your bots, or /cancel."}
}
switch state.Command {
case botFatherCmdToken:
defer s.clearState(ctx, state.UserID)
profile, found, err := s.bots.GetBot(ctx, chosen.ID)
if err != nil || !found || profile.TokenSecret == "" {
if err != nil {
s.log.Error("botfather: get bot", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
}
return internalReply()
}
head := fmt.Sprintf("You can use this token to access the HTTP API for @%s:\n", chosen.Username)
return tokenReply(head, domain.FormatBotToken(chosen.ID, profile.TokenSecret), "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdRevoke:
defer s.clearState(ctx, state.UserID)
token, err := s.RevokeBotToken(ctx, state.UserID, chosen.ID)
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
// token 已换(旧 token 不可再登录),但未能终止已建立的 session——
// 诚实告知用户重试,不谎称已止血。
head := fmt.Sprintf("Token for @%s has been changed, so the old token can no longer log in. But I couldn't terminate sessions that are already logged in — please run /revoke again to make sure they're cut off. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case err != nil:
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
// 选中 bot 后进入收值 step,把目标 bot 暂存进 Draft。
state.Step = botFatherStepValue
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
state.Draft[botFatherDraftBotUsername] = chosen.Username
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: valuePrompt(state.Command, chosen.Username)}
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
}
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply {
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
username := state.Draft[botFatherDraftBotUsername]
if botID == 0 {
s.clearState(ctx, state.UserID)
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /help."}
}
// 防御性复核 owner(状态是服务端存的,正常已是 owned bot)。
if owns, err := s.OwnsBot(ctx, state.UserID, botID); err != nil {
s.log.Error("botfather: owns bot", zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
} else if !owns {
s.clearState(ctx, state.UserID)
return botReply{Text: "That bot is no longer available."}
}
var (
reply botReply
err error
)
switch state.Command {
case botFatherCmdSetName:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetName: true, Name: text})
reply = okReply(err, fmt.Sprintf("Success! Name updated for @%s.", username), "Sorry, that name is invalid. Please try a different one.")
case botFatherCmdSetDescription:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetDescription: true, Description: text})
reply = okReply(err, "Success! Description updated.", "Sorry, that description is too long.")
case botFatherCmdSetAbout:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetAbout: true, About: text})
reply = okReply(err, "Success! About section updated.", "Sorry, that about text is too long.")
case botFatherCmdSetCommands:
reply, err = s.applySetCommands(ctx, botID, text)
case botFatherCmdSetInline:
reply, err = s.applySetInline(ctx, botID, text)
case botFatherCmdSetInlineGeo:
reply, err = s.applySetInlineGeo(ctx, botID, text)
case botFatherCmdSetJoinGroups:
reply, err = s.applyToggle(ctx, botID, text, true)
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
if err != nil {
// 校验类错误已转成提示文案;非校验错误内部已记日志。保留 state 让用户重试。
if reply.Text == "" {
return internalReply()
}
return reply
}
s.clearState(ctx, state.UserID)
return reply
}
// applySetCommands 解析多行 "command - Description"(/empty 清空)并写入。
func (s *Service) applySetCommands(ctx context.Context, botID int64, text string) (botReply, error) {
var commands []domain.BotCommand
if strings.TrimSpace(text) != "/empty" {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cmd, desc, ok := strings.Cut(line, "-")
if !ok {
return botReply{Text: "Invalid format. Each line must be: command - Description. Try again or /cancel."}, domain.ErrBotCommandInvalid
}
commands = append(commands, domain.BotCommand{
Command: strings.TrimSpace(cmd),
Description: strings.TrimSpace(desc),
})
}
}
if _, err := s.SetBotCommands(ctx, botID, commands); err != nil {
return botReply{Text: "Invalid command list. Each command must be 1-32 chars (letters, digits, underscores) with a non-empty description. Try again or /cancel."}, err
}
return botReply{Text: "Success! Command list updated. /help"}, nil
}
// applySetInline 写入 inline placeholder;/empty 清空并关闭 inline mode。
func (s *Service) applySetInline(ctx context.Context, botID int64, text string) (botReply, error) {
placeholder := strings.TrimSpace(text)
if placeholder == "/empty" {
placeholder = ""
}
if _, err := s.SetInlinePlaceholder(ctx, botID, placeholder); err != nil {
return botReply{Text: fmt.Sprintf("Sorry, inline placeholder must be at most %d characters. Try again or /cancel.", domain.MaxBotInlinePlaceholderLen)}, err
}
if placeholder == "" {
return botReply{Text: "Success! Inline mode disabled. /help"}, nil
}
return botReply{Text: "Success! Inline settings updated. /help"}, nil
}
func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text string) (botReply, error) {
var enabled bool
switch strings.ToLower(strings.TrimSpace(text)) {
case "enable", "on", "yes":
enabled = true
case "disable", "off", "no":
enabled = false
default:
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
}
if _, err := s.SetInlineGeo(ctx, botID, enabled); err != nil {
s.log.Error("botfather: set inline geo", zap.Int64("bot_user_id", botID), zap.Error(err))
return botReply{}, err
}
state := "disabled"
if enabled {
state = "enabled"
}
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
}
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool
switch strings.ToLower(strings.TrimSpace(text)) {
case "enable", "on", "yes":
on = true
case "disable", "off", "no":
on = false
default:
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
}
var err error
if join {
_, err = s.SetJoinGroups(ctx, botID, on)
} else {
_, err = s.SetPrivacy(ctx, botID, on)
}
if err != nil {
s.log.Error("botfather: apply toggle", zap.Int64("bot_user_id", botID), zap.Bool("join", join), zap.Error(err))
return botReply{}, err
}
what := "Group privacy"
if join {
what = "Group joining"
}
state := "disabled"
if on {
state = "enabled"
}
return botReply{Text: fmt.Sprintf("Success! %s is now %s.", what, state)}, nil
}
// okReply 把 service 调用结果转成提示:err==nil 回成功,否则回校验失败提示。
func okReply(err error, ok, fail string) botReply {
if err != nil {
return botReply{Text: fail}
}
return botReply{Text: ok}
}
// clearState 删除 BotFather 对话状态(忽略错误,仅记日志)。
func (s *Service) clearState(ctx context.Context, userID int64) {
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
}
}
type ownedBot struct {
profile domain.BotProfile
user domain.User
}
func (s *Service) ownedBots(ctx context.Context, ownerUserID int64) ([]ownedBot, error) {
profiles, err := s.bots.ListBotsByOwner(ctx, ownerUserID)
if err != nil {
return nil, err
}
if len(profiles) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.BotUserID)
}
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return nil, err
}
byID := make(map[int64]domain.User, len(users))
for _, u := range users {
byID[u.ID] = u
}
out := make([]ownedBot, 0, len(profiles))
for _, p := range profiles {
u, ok := byID[p.BotUserID]
if !ok {
continue
}
out = append(out, ownedBot{profile: p, user: u})
}
sort.Slice(out, func(i, j int) bool { return out[i].user.ID < out[j].user.ID })
return out, nil
}
func (s *Service) ownedBotUsernames(ctx context.Context, ownerUserID int64) ([]string, error) {
owned, err := s.ownedBots(ctx, ownerUserID)
if err != nil {
return nil, err
}
out := make([]string, 0, len(owned))
for _, b := range owned {
if b.user.Username != "" {
out = append(out, b.user.Username)
}
}
return out, nil
}
// tokenReply 拼装带 code entity 的 token 消息(全 ASCII 文本,offset 按字节即按
// UTF-16 code unit 成立)。
func tokenReply(head, token, tail string) botReply {
return botReply{
Text: head + token + tail,
Entities: []domain.MessageEntity{
{Type: domain.MessageEntityCode, Offset: len(head), Length: len(token)},
},
}
}
func internalReply() botReply {
return botReply{Text: "Something went wrong on my side. Please try again later."}
}
// parseBotCommand 解析行首 "/cmd"(容忍 "/cmd@BotFather" 与尾随参数),返回小写命令名。
func parseBotCommand(text string) (string, bool) {
if !strings.HasPrefix(text, "/") {
return "", false
}
cmd := text[1:]
if i := strings.IndexAny(cmd, " \t\n"); i >= 0 {
cmd = cmd[:i]
}
if i := strings.IndexByte(cmd, '@'); i >= 0 {
cmd = cmd[:i]
}
if cmd == "" {
return "", false
}
return strings.ToLower(cmd), true
}

View file

@ -0,0 +1,413 @@
package bots
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// makeBot 创建一个 owned bot,返回其 user。
func makeBot(t *testing.T, svc *Service, owner domain.User, name, username string) domain.User {
t.Helper()
u, _, err := svc.CreateBot(context.Background(), owner.ID, name, username)
if err != nil {
t.Fatalf("create bot %q: %v", username, err)
}
return u
}
func TestSetBotCommandsAndBump(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2000")
bot := makeBot(t, svc, owner, "Cmd Bot", "cmd_test_bot")
ctx := context.Background()
before, _, _ := users.ByID(ctx, bot.ID)
v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{
{Command: "/Start", Description: "begin"},
{Command: "help", Description: "show help"},
})
if err != nil {
t.Fatalf("set commands: %v", err)
}
if v1 <= before.BotInfoVersion {
t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, v1)
}
got, err := svc.GetBotCommands(ctx, bot.ID)
if err != nil {
t.Fatalf("get commands: %v", err)
}
if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" {
t.Fatalf("commands = %+v, want normalized [start,help]", got)
}
// 非法命令名 → ErrBotCommandInvalid。
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "bad name!", Description: "x"}}); err != domain.ErrBotCommandInvalid {
t.Fatalf("invalid command err = %v, want ErrBotCommandInvalid", err)
}
// 空描述 → 非法。
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "ok", Description: ""}}); err != domain.ErrBotCommandInvalid {
t.Fatalf("empty desc err = %v, want ErrBotCommandInvalid", err)
}
// 清空。
if _, err := svc.SetBotCommands(ctx, bot.ID, nil); err != nil {
t.Fatalf("reset commands: %v", err)
}
if got, _ := svc.GetBotCommands(ctx, bot.ID); len(got) != 0 {
t.Fatalf("after reset commands = %+v, want empty", got)
}
}
func TestSetBotInfoFields(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2001")
bot := makeBot(t, svc, owner, "Info Bot", "info_test_bot")
ctx := context.Background()
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{
SetName: true, Name: "Renamed Bot",
SetAbout: true, About: "about line",
SetDescription: true, Description: "what this bot does",
}); err != nil {
t.Fatalf("set bot info: %v", err)
}
name, about, description, err := svc.GetBotInfo(ctx, bot.ID)
if err != nil {
t.Fatalf("get bot info: %v", err)
}
if name != "Renamed Bot" || about != "about line" || description != "what this bot does" {
t.Fatalf("bot info = name=%q about=%q desc=%q", name, about, description)
}
// name 落到 users.first_name。
u, _, _ := users.ByID(ctx, bot.ID)
if u.FirstName != "Renamed Bot" || u.About != "about line" {
t.Fatalf("user row = first_name=%q about=%q, want name/about persisted", u.FirstName, u.About)
}
// 空 name 非法。
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{SetName: true, Name: " "}); err != domain.ErrBotInfoInvalid {
t.Fatalf("empty name err = %v, want ErrBotInfoInvalid", err)
}
// 全空更新非法。
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{}); err != domain.ErrBotInfoInvalid {
t.Fatalf("noop update err = %v, want ErrBotInfoInvalid", err)
}
}
func TestSetBotMenuButton(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2002")
bot := makeBot(t, svc, owner, "Menu Bot", "menu_test_bot")
ctx := context.Background()
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{
Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://example.com/app",
}); err != nil {
t.Fatalf("set menu button: %v", err)
}
btn, err := svc.GetBotMenuButton(ctx, bot.ID)
if err != nil {
t.Fatalf("get menu button: %v", err)
}
if btn.Type != domain.BotMenuButtonWebView || btn.Text != "Open" || btn.URL != "https://example.com/app" {
t.Fatalf("menu button = %+v", btn)
}
// webview 缺 text/url 非法。
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, URL: "https://x"}); err != domain.ErrBotMenuButtonInvalid {
t.Fatalf("webview missing text err = %v, want ErrBotMenuButtonInvalid", err)
}
// commands 型清空 text/url。
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonCommands, Text: "x", URL: "y"}); err != nil {
t.Fatalf("set commands menu: %v", err)
}
if btn, _ := svc.GetBotMenuButton(ctx, bot.ID); btn.Type != domain.BotMenuButtonCommands || btn.Text != "" || btn.URL != "" {
t.Fatalf("commands menu = %+v, want cleared text/url", btn)
}
}
func TestSetInlinePlaceholder(t *testing.T) {
svc, users, bots, _ := newTestService(t)
owner := newOwner(t, users, "+2010")
bot := makeBot(t, svc, owner, "Inline Bot", "inline_test_bot")
ctx := context.Background()
before, _, _ := users.ByID(ctx, bot.ID)
version, err := svc.SetInlinePlaceholder(ctx, bot.ID, "Search things")
if err != nil {
t.Fatalf("set inline placeholder: %v", err)
}
if version <= before.BotInfoVersion {
t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, version)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search things" {
t.Fatalf("inline placeholder = %q, want Search things", p.InlinePlaceholder)
}
if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, strings.Repeat("x", domain.MaxBotInlinePlaceholderLen+1)); err != domain.ErrBotInlinePlaceholderInvalid {
t.Fatalf("overlong placeholder err = %v, want ErrBotInlinePlaceholderInvalid", err)
}
if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, ""); err != nil {
t.Fatalf("clear inline placeholder: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" {
t.Fatalf("inline placeholder after clear = %q, want empty", p.InlinePlaceholder)
}
}
func TestSetJoinGroupsAndPrivacy(t *testing.T) {
svc, users, bots, _ := newTestService(t)
owner := newOwner(t, users, "+2003")
bot := makeBot(t, svc, owner, "Flag Bot", "flag_test_bot")
ctx := context.Background()
// joingroups disable → nochats=true。
if _, err := svc.SetJoinGroups(ctx, bot.ID, false); err != nil {
t.Fatalf("set join groups: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats {
t.Fatalf("nochats = false, want true after disable join")
}
if _, err := svc.SetJoinGroups(ctx, bot.ID, true); err != nil {
t.Fatalf("re-enable join: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.Nochats {
t.Fatalf("nochats = true, want false after enable join")
}
// privacy enable → chat_history=false(隐私开=只收命令)。
if _, err := svc.SetPrivacy(ctx, bot.ID, true); err != nil {
t.Fatalf("set privacy: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.ChatHistory {
t.Fatalf("chat_history = true, want false when privacy enabled")
}
if _, err := svc.SetPrivacy(ctx, bot.ID, false); err != nil {
t.Fatalf("disable privacy: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.ChatHistory {
t.Fatalf("chat_history = false, want true when privacy disabled")
}
before, _, _ := users.ByID(ctx, bot.ID)
version, err := svc.SetInlineGeo(ctx, bot.ID, true)
if err != nil {
t.Fatalf("set inline geo: %v", err)
}
if version <= before.BotInfoVersion {
t.Fatalf("bot_info_version not bumped for inline geo: before=%d after=%d", before.BotInfoVersion, version)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo {
t.Fatalf("inline_geo = false, want true after enable")
}
if _, err := svc.SetInlineGeo(ctx, bot.ID, false); err != nil {
t.Fatalf("disable inline geo: %v", err)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo {
t.Fatalf("inline_geo = true, want false after disable")
}
}
func TestOwnsBot(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2004")
other := newOwner(t, users, "+2005")
bot := makeBot(t, svc, owner, "Owned Bot", "owned_test_bot")
ctx := context.Background()
if owns, err := svc.OwnsBot(ctx, owner.ID, bot.ID); err != nil || !owns {
t.Fatalf("owner OwnsBot = %v,%v, want true", owns, err)
}
if owns, _ := svc.OwnsBot(ctx, other.ID, bot.ID); owns {
t.Fatalf("non-owner OwnsBot = true, want false")
}
// BotFather 自身不算任何人 owned。
if owns, _ := svc.OwnsBot(ctx, domain.BotFatherUserID, domain.BotFatherUserID); owns {
t.Fatalf("BotFather self OwnsBot = true, want false")
}
}
func TestBotFatherSetCommandsFlow(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+2006")
bot := makeBot(t, svc, owner, "Flow Bot", "flow_test_bot")
ctx := context.Background()
if reply := sendToBotFather(t, svc, messages, owner, "/setcommands"); !strings.Contains(reply, "username") {
t.Fatalf("/setcommands reply = %q, want pick bot", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "@flow_test_bot"); !strings.Contains(reply, "list of commands") {
t.Fatalf("choose reply = %q, want value prompt", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "start - Begin\nhelp - Show help"); !strings.Contains(reply, "Success") {
t.Fatalf("set commands reply = %q, want success", reply)
}
got, _, _ := bots.GetBot(ctx, bot.ID)
if len(got.Commands) != 2 || got.Commands[0].Command != "start" {
t.Fatalf("stored commands = %+v, want [start,help]", got.Commands)
}
// 非法格式(无 -)保留 state 提示重试。
sendToBotFather(t, svc, messages, owner, "/setcommands")
sendToBotFather(t, svc, messages, owner, "@flow_test_bot")
if reply := sendToBotFather(t, svc, messages, owner, "noseparator"); !strings.Contains(reply, "Invalid format") {
t.Fatalf("invalid format reply = %q", reply)
}
}
func TestBotFatherSetNameAndAboutFlow(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+2007")
bot := makeBot(t, svc, owner, "Name Bot", "name_test_bot")
ctx := context.Background()
sendToBotFather(t, svc, messages, owner, "/setname")
sendToBotFather(t, svc, messages, owner, "@name_test_bot")
if reply := sendToBotFather(t, svc, messages, owner, "Brand New Name"); !strings.Contains(reply, "Success") {
t.Fatalf("setname reply = %q", reply)
}
if u, _, _ := users.ByID(ctx, bot.ID); u.FirstName != "Brand New Name" {
t.Fatalf("bot first_name = %q, want renamed", u.FirstName)
}
sendToBotFather(t, svc, messages, owner, "/setabouttext")
sendToBotFather(t, svc, messages, owner, "@name_test_bot")
if reply := sendToBotFather(t, svc, messages, owner, "my about"); !strings.Contains(reply, "Success") {
t.Fatalf("setabouttext reply = %q", reply)
}
if u, _, _ := users.ByID(ctx, bot.ID); u.About != "my about" {
t.Fatalf("bot about = %q, want updated", u.About)
}
}
func TestBotFatherSetJoinGroupsFlow(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+2008")
bot := makeBot(t, svc, owner, "Join Bot", "join_test_bot")
ctx := context.Background()
sendToBotFather(t, svc, messages, owner, "/setjoingroups")
sendToBotFather(t, svc, messages, owner, "@join_test_bot")
if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") {
t.Fatalf("setjoingroups disable reply = %q", reply)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats {
t.Fatalf("nochats = false, want true after disable")
}
// 非 enable/disable 输入保留 state 提示。
sendToBotFather(t, svc, messages, owner, "/setprivacy")
sendToBotFather(t, svc, messages, owner, "@join_test_bot")
if reply := sendToBotFather(t, svc, messages, owner, "maybe"); !strings.Contains(reply, "enable") {
t.Fatalf("bad toggle reply = %q, want hint", reply)
}
}
func TestBotFatherSetInlineFlow(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+2011")
bot := makeBot(t, svc, owner, "Inline Flow", "inline_flow_bot")
ctx := context.Background()
if reply := sendToBotFather(t, svc, messages, owner, "/setinline"); !strings.Contains(reply, "inline mode") {
t.Fatalf("/setinline reply = %q, want pick bot", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "placeholder") {
t.Fatalf("choose reply = %q, want placeholder prompt", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "Search inline stuff"); !strings.Contains(reply, "Success") {
t.Fatalf("setinline reply = %q, want success", reply)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search inline stuff" {
t.Fatalf("inline placeholder = %q", p.InlinePlaceholder)
}
sendToBotFather(t, svc, messages, owner, "/setinline")
sendToBotFather(t, svc, messages, owner, "@inline_flow_bot")
if reply := sendToBotFather(t, svc, messages, owner, "/empty"); !strings.Contains(reply, "disabled") {
t.Fatalf("setinline /empty reply = %q, want disabled", reply)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" {
t.Fatalf("inline placeholder after /empty = %q, want empty", p.InlinePlaceholder)
}
if reply := sendToBotFather(t, svc, messages, owner, "/setinlinegeo"); !strings.Contains(reply, "location requests") {
t.Fatalf("/setinlinegeo reply = %q, want pick bot", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "location") {
t.Fatalf("choose inline geo reply = %q, want location prompt", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "enable"); !strings.Contains(reply, "Success") {
t.Fatalf("setinlinegeo enable reply = %q, want success", reply)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo {
t.Fatalf("inline_geo = false, want enabled")
}
sendToBotFather(t, svc, messages, owner, "/setinlinegeo")
sendToBotFather(t, svc, messages, owner, "@inline_flow_bot")
if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") {
t.Fatalf("setinlinegeo disable reply = %q, want success", reply)
}
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo {
t.Fatalf("inline_geo = true, want disabled")
}
if reply := sendToBotFather(t, svc, messages, owner, "/setinlinefeedback"); !strings.Contains(reply, "not supported yet") {
t.Fatalf("/setinlinefeedback reply = %q, want explicit stub", reply)
}
}
func TestRevokeBotTokenRevokesSessions(t *testing.T) {
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
rev := &captureRevoker{}
svc := NewService(users, bots, messages)
svc.SetRouterHooks(rev)
owner := newOwner(t, users, "+2009")
bot := makeBot(t, svc, owner, "Rev Bot", "rev_test_bot")
ctx := context.Background()
if _, err := svc.RevokeBotToken(ctx, owner.ID, bot.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if rev.botUserID != bot.ID {
t.Fatalf("RevokeBotSessions called with %d, want %d", rev.botUserID, bot.ID)
}
}
func TestBotWriteAccessGrant(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2012")
bot := makeBot(t, svc, owner, "Write Bot", "write_access_bot")
ctx := context.Background()
can, err := svc.CanSendMessage(ctx, owner.ID, bot.ID)
if err != nil || can {
t.Fatalf("CanSendMessage before allow = %v,%v, want false,nil", can, err)
}
created, err := svc.AllowSendMessage(ctx, owner.ID, bot.ID, true)
if err != nil || !created {
t.Fatalf("AllowSendMessage first = %v,%v, want true,nil", created, err)
}
can, err = svc.CanSendMessage(ctx, owner.ID, bot.ID)
if err != nil || !can {
t.Fatalf("CanSendMessage after allow = %v,%v, want true,nil", can, err)
}
created, err = svc.AllowSendMessage(ctx, owner.ID, bot.ID, true)
if err != nil || created {
t.Fatalf("AllowSendMessage repeat = %v,%v, want false,nil", created, err)
}
}
type captureRevoker struct {
botUserID int64
pushedCommandsTo int64
pushedCommands []domain.BotCommand
}
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
c.botUserID = botUserID
return nil
}
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
c.pushedCommandsTo = botUserID
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
}

View file

@ -0,0 +1,373 @@
package bots
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"unicode/utf8"
"telesrv/internal/domain"
)
const (
defaultMainAppShortName = "main"
requestedWebViewButtonTTL = 10 * time.Minute
webViewCustomMethodQueryTTL = 5 * time.Minute
)
func (s *Service) UpsertBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, int, error) {
clean, err := s.normalizeBotApp(ctx, botUserID, app)
if err != nil {
return domain.BotApp{}, 0, err
}
out, version, err := s.bots.UpsertBotApp(ctx, clean)
if err != nil {
return domain.BotApp{}, 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return out, version, nil
}
func (s *Service) EnsureMenuBotApp(ctx context.Context, botUserID int64, button domain.BotMenuButton) (domain.BotApp, int, error) {
if button.Type != domain.BotMenuButtonWebView {
return domain.BotApp{}, 0, nil
}
app, version, err := s.UpsertBotApp(ctx, botUserID, domain.BotApp{
BotUserID: botUserID,
ShortName: defaultMainAppShortName,
Title: button.Text,
URL: button.URL,
Main: true,
RequestWriteAccess: true,
})
if err != nil {
return domain.BotApp{}, 0, err
}
if _, err := s.UpsertAttachMenuBot(ctx, botUserID, domain.BotAttachMenuBot{
BotUserID: botUserID,
AppID: app.ID,
ShortName: app.ShortName,
RequestWriteAccess: app.RequestWriteAccess,
ShowInAttachMenu: true,
ShowInSideMenu: true,
}); err != nil {
return domain.BotApp{}, 0, err
}
return app, version, nil
}
func (s *Service) normalizeBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.BotApp{}, domain.ErrBotAppInvalid
}
app.BotUserID = botUserID
app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName))
app.Title = strings.TrimSpace(app.Title)
app.Description = strings.TrimSpace(app.Description)
app.URL = strings.TrimSpace(app.URL)
if !validBotAppShortName(app.ShortName) {
return domain.BotApp{}, domain.ErrBotAppShortNameInvalid
}
if app.Title == "" || utf8.RuneCountInString(app.Title) > domain.MaxBotAppTitleLen ||
utf8.RuneCountInString(app.Description) > domain.MaxBotAppDescriptionLen ||
len(app.URL) > domain.MaxBotAppURLLen || !validHTTPSURL(app.URL) {
return domain.BotApp{}, domain.ErrBotAppInvalid
}
if app.ID == 0 {
app.ID = stableBotAppInt64("bot-app-id", fmt.Sprint(botUserID), app.ShortName)
}
if app.AccessHash == 0 {
if existing, found, err := s.bots.GetBotAppByShortName(ctx, botUserID, app.ShortName); err == nil && found {
app.AccessHash = existing.AccessHash
if app.ID == 0 {
app.ID = existing.ID
}
}
}
if app.AccessHash == 0 {
app.AccessHash = stableBotAppInt64("bot-app-access", fmt.Sprint(botUserID), app.ShortName, app.URL)
}
app.Hash = botAppHash(app)
return app, nil
}
func (s *Service) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) {
if s == nil || s.bots == nil {
return domain.BotApp{}, false, nil
}
return s.bots.GetBotAppByID(ctx, appID, accessHash)
}
func (s *Service) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) {
if s == nil || s.bots == nil {
return domain.BotApp{}, false, nil
}
return s.bots.GetBotAppByShortName(ctx, botUserID, strings.ToLower(strings.TrimSpace(shortName)))
}
func (s *Service) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) {
if s == nil || s.bots == nil {
return domain.BotApp{}, false, nil
}
return s.bots.GetMainBotApp(ctx, botUserID)
}
func (s *Service) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) {
if s == nil || s.bots == nil {
return nil, nil
}
return s.bots.ListBotApps(ctx, botUserID)
}
func (s *Service) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) {
if s == nil || s.bots == nil {
return domain.BotAppSettings{}, false, nil
}
return s.bots.GetBotAppSettings(ctx, botUserID)
}
func (s *Service) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return 0, domain.ErrBotAppInvalid
}
version, err := s.bots.UpsertBotAppSettings(ctx, botUserID, settings)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
func (s *Service) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) {
if s == nil || s.bots == nil {
return nil, nil
}
return s.bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
}
func (s *Service) UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) {
if s == nil || s.bots == nil {
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
}
if media.ID == 0 {
items, err := s.bots.ListBotAppPreviewMedia(ctx, media.BotUserID, media.AppID)
if err != nil {
return domain.BotAppPreviewMedia{}, 0, err
}
if len(items) >= domain.MaxBotPreviewMedia {
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
}
}
out, version, err := s.bots.UpsertBotAppPreviewMedia(ctx, media)
if err != nil {
return domain.BotAppPreviewMedia{}, 0, err
}
s.invalidateBotReadCaches(ctx, media.BotUserID)
return out, version, nil
}
func (s *Service) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) {
version, err := s.bots.DeleteBotAppPreviewMedia(ctx, botUserID, appID, mediaID)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
func (s *Service) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) {
version, err := s.bots.ReorderBotAppPreviewMedia(ctx, botUserID, appID, mediaIDs)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
func (s *Service) UpsertAttachMenuBot(ctx context.Context, botUserID int64, bot domain.BotAttachMenuBot) (int, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return 0, domain.ErrBotAttachMenuInvalid
}
bot.BotUserID = botUserID
bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName))
if bot.ShortName == "" {
if app, found, err := s.GetMainBotApp(ctx, botUserID); err == nil && found {
bot.AppID = app.ID
bot.ShortName = app.ShortName
bot.HasSettings = app.HasSettings
bot.RequestWriteAccess = app.RequestWriteAccess
}
}
if !validBotAppShortName(bot.ShortName) {
return 0, domain.ErrBotAttachMenuInvalid
}
if len(bot.PeerTypes) == 0 {
bot.PeerTypes = []string{"pm", "chat", "megagroup", "broadcast"}
}
if len(bot.PeerTypes) > domain.MaxBotAttachMenuPeerTypes || len(bot.Icons) > domain.MaxBotAttachMenuIcons {
return 0, domain.ErrBotAttachMenuInvalid
}
if !bot.ShowInAttachMenu && !bot.ShowInSideMenu {
bot.ShowInAttachMenu = true
bot.ShowInSideMenu = true
}
version, err := s.bots.UpsertAttachMenuBot(ctx, bot)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
func (s *Service) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) {
if s == nil || s.bots == nil {
return domain.BotAttachMenuBot{}, false, nil
}
return s.bots.GetAttachMenuBot(ctx, botUserID)
}
func (s *Service) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) {
if s == nil || s.bots == nil {
return nil, nil
}
return s.bots.ListAttachMenuBots(ctx)
}
func (s *Service) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) {
if s == nil || s.bots == nil {
return domain.BotAttachMenuState{}, false, nil
}
return s.bots.GetAttachMenuState(ctx, userID, botUserID)
}
func (s *Service) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) {
if s == nil || s.bots == nil {
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
}
return s.bots.SetAttachMenuState(ctx, state)
}
func (s *Service) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) (domain.BotRequestedWebViewButton, error) {
if s == nil || s.bots == nil || button.BotUserID == 0 || button.UserID == 0 || button.ButtonID == 0 {
return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid
}
if button.WebAppReqID == "" {
rnd, err := randomInt64()
if err != nil {
return domain.BotRequestedWebViewButton{}, err
}
button.WebAppReqID = hex.EncodeToString([]byte(fmt.Sprintf("%d:%d:%d", button.BotUserID, button.UserID, rnd)))
}
if button.MaxQuantity <= 0 {
button.MaxQuantity = 1
}
if button.MaxQuantity > domain.MaxBotRequestedPeerQuantity {
return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid
}
now := s.now()
if button.CreatedAt.IsZero() {
button.CreatedAt = now
}
if button.ExpiresAt.IsZero() {
button.ExpiresAt = now.Add(requestedWebViewButtonTTL)
}
if err := s.bots.SaveRequestedWebViewButton(ctx, button); err != nil {
return domain.BotRequestedWebViewButton{}, err
}
return button, nil
}
func (s *Service) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) (domain.BotRequestedWebViewButton, bool, error) {
if s == nil || s.bots == nil {
return domain.BotRequestedWebViewButton{}, false, nil
}
return s.bots.GetRequestedWebViewButton(ctx, botUserID, userID, reqID)
}
func (s *Service) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) error {
if s == nil || s.bots == nil {
return nil
}
return s.bots.DeleteRequestedWebViewButton(ctx, botUserID, userID, reqID)
}
func (s *Service) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error {
if s == nil || s.bots == nil {
return domain.ErrBotNotFound
}
return s.bots.SetBotEmojiStatusPermission(ctx, botUserID, userID, allowed)
}
func (s *Service) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) {
if s == nil || s.bots == nil {
return false, nil
}
return s.bots.BotEmojiStatusPermission(ctx, botUserID, userID)
}
func (s *Service) PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error) {
method = strings.TrimSpace(method)
if s == nil || s.bots == nil || botUserID == 0 || userID == 0 || method == "" || len(method) > domain.MaxBotCustomMethodLen || len(paramsJSON) > domain.MaxBotCustomMethodPayloadLen {
return domain.BotWebViewCustomMethodQuery{}, domain.ErrBotCustomMethodUnavailable
}
rnd, err := randomInt64()
if err != nil {
return domain.BotWebViewCustomMethodQuery{}, err
}
now := s.now()
query := domain.BotWebViewCustomMethodQuery{
ID: fmt.Sprintf("%d:%d:%d:%d", botUserID, userID, now.UnixNano(), rnd),
BotUserID: botUserID,
UserID: userID,
CustomMethod: method,
ParamsJSON: paramsJSON,
CreatedAt: now,
ExpiresAt: now.Add(webViewCustomMethodQueryTTL),
}
if err := s.bots.PutWebViewCustomMethodQuery(ctx, query); err != nil {
return domain.BotWebViewCustomMethodQuery{}, err
}
return query, nil
}
func validHTTPSURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && u.Scheme == "https" && u.Host != ""
}
func validBotAppShortName(shortName string) bool {
if shortName == "" || len(shortName) > domain.MaxBotAppShortNameLen {
return false
}
for _, r := range shortName {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
default:
return false
}
}
return true
}
func botAppHash(app domain.BotApp) int64 {
return stableBotAppInt64("bot-app-hash", fmt.Sprint(app.BotUserID), app.ShortName, app.Title, app.Description, app.URL, fmt.Sprint(app.PhotoID), fmt.Sprint(app.DocumentID), fmt.Sprint(app.Inactive), fmt.Sprint(app.RequestWriteAccess), fmt.Sprint(app.HasSettings), fmt.Sprint(app.Main))
}
func stableBotAppInt64(parts ...string) int64 {
h := sha256.New()
for _, part := range parts {
_, _ = h.Write([]byte(part))
_, _ = h.Write([]byte{0})
}
sum := h.Sum(nil)
value := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
if value == 0 {
return 1
}
return value
}

View file

@ -0,0 +1,130 @@
package bots
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
botProfileCacheMaxEntries = 100000
botProfileCacheTTL = 24 * time.Hour
)
// botProfileValue 是缓存值,found=false 表示「查过但该 bot 不存在」(负缓存),
// 避免未注册 bot 的 id 反复打后端。
type botProfileValue struct {
profile domain.BotProfile
found bool
}
// botProfileCache 由统一缓存原语承载(LRU 单条驱逐 / epoch 守卫 / clone 内建)。
// 单个走 GetOrLoad,批量走 GetOrLoadBatch(一次 LoadEpoch + 合批 load + per-key epoch 写回)。
type botProfileCache struct {
cache *readmodelcache.Cache[int64, botProfileValue]
}
func newBotProfileCache(max int, ttl time.Duration) *botProfileCache {
cache := readmodelcache.New[int64, botProfileValue](readmodelcache.Config[int64, botProfileValue]{
MaxEntries: max,
TTL: ttl,
Clone: cloneBotProfileValue,
})
if cache == nil {
return nil
}
return &botProfileCache{cache: cache}
}
// getOrLoad 解析单个 bot;load 返回 (profile, found, err)。
func (c *botProfileCache) getOrLoad(ctx context.Context, botUserID int64, load func() (domain.BotProfile, bool, error)) (domain.BotProfile, bool, error) {
if c == nil || botUserID == 0 {
return load()
}
v, err := c.cache.GetOrLoad(ctx, botUserID, func() (botProfileValue, error) {
profile, found, err := load()
if err != nil {
return botProfileValue{}, err
}
return botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found}, nil
})
if err != nil {
return domain.BotProfile{}, false, err
}
return v.profile, v.found, nil
}
// getMany 批量解析;loadMissing 返回 misses 的 profiles(仅存在的;缺失即视为负结果)。
// 返回的 map 只含存在(found)的 bot,与旧 getMany 语义一致。
func (c *botProfileCache) getMany(ctx context.Context, ids []int64, loadMissing func(context.Context, []int64) (map[int64]domain.BotProfile, error)) (map[int64]domain.BotProfile, error) {
unique := uniqueBotUserIDs(ids)
if c == nil {
return loadMissing(ctx, unique)
}
values, err := c.cache.GetOrLoadBatch(ctx, unique,
func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门
func(ctx context.Context, missing []int64) (map[int64]botProfileValue, error) {
loaded, err := loadMissing(ctx, missing)
if err != nil {
return nil, err
}
out := make(map[int64]botProfileValue, len(missing))
for _, id := range missing {
profile, found := loaded[id]
out[id] = botProfileValue{profile: normalizeBotProfile(id, profile, found), found: found}
}
return out, nil
})
if err != nil {
return nil, err
}
out := make(map[int64]domain.BotProfile, len(values))
for id, v := range values {
if v.found {
out[id] = v.profile
}
}
return out, nil
}
func (c *botProfileCache) put(botUserID int64, profile domain.BotProfile, found bool) {
if c == nil || botUserID == 0 {
return
}
c.cache.Store(botUserID, botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found})
}
func (c *botProfileCache) delete(botUserID int64) {
if c == nil || botUserID == 0 {
return
}
c.cache.Invalidate(botUserID)
}
func (c *botProfileCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func normalizeBotProfile(botUserID int64, profile domain.BotProfile, found bool) domain.BotProfile {
if found && profile.BotUserID == 0 {
profile.BotUserID = botUserID
}
return profile
}
func cloneBotProfileValue(v botProfileValue) botProfileValue {
v.profile = cloneBotProfile(v.profile)
return v
}
func cloneBotProfile(profile domain.BotProfile) domain.BotProfile {
if len(profile.Commands) > 0 {
profile.Commands = append([]domain.BotCommand(nil), profile.Commands...)
}
return profile
}

View file

@ -0,0 +1,645 @@
// Package bots 实现 bot 账号业务:BotFather 对话状态机、bot 创建、token 管理与
// botInfo 查询。bot 登录(auth.importBotAuthorization)在 app/auth 经 store.BotStore
// 直接校验 token,不依赖本包。
package bots
import (
"context"
"crypto/rand"
"fmt"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// blockChecker 报告 userID 是否 block 了 blockedUserID(store.ContactStore 子集)。
type blockChecker interface {
IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error)
}
type publicChannelUsernameResolver interface {
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
}
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现):
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
// authorization + 强制断连)。
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。
type RouterHooks interface {
RevokeBotSessions(ctx context.Context, botUserID int64) error
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
}
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
// 串行执行(状态机 RMW 原子 + 回复保序),不同用户并发;固定大小不随用户数增长。
const replyLockStripes = 256
// Service 提供 bot 账号业务。
type Service struct {
users store.UserStore
bots store.BotStore
messages store.MessageStore
blocker blockChecker
channels publicChannelUsernameResolver
hooks RouterHooks
userCache store.UserCache
cache *botProfileCache
log *zap.Logger
now func() time.Time
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
replySeq atomic.Int64
replyLocks [replyLockStripes]sync.Mutex
}
// Option 调整 bots 服务的可选依赖。
type Option func(*Service)
// WithLogger 注入日志器(缺省 zap.NewNop)。
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// WithNow 注入时钟(测试用)。
func WithNow(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithBlockChecker 注入 block 关系查询:BotFather 回复前据此设置 RecipientBlocked,
// 用户 block 掉 BotFather 后不再向其收件箱投递(对齐 rpc 发送路径语义)。
func WithBlockChecker(c blockChecker) Option {
return func(s *Service) {
if c != nil {
s.blocker = c
}
}
}
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
// username 预检,避免 bot 与 public channel 产生同名可见入口。
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
return func(s *Service) {
if c != nil {
s.channels = c
}
}
}
// WithUserCache 注入 users 基础资料缓存:bot 元数据写入(first_name/about/
// bot_info_version bump)后必须失效该 bot 的缓存条目,否则 TTL 内 getUsers
// 返回旧 first_name 与旧 bot_info_version——version bump 被缓存遮蔽,客户端
// 感知不到变更、不会重拉 getFullUser。
func WithUserCache(c store.UserCache) Option {
return func(s *Service) {
if c != nil {
s.userCache = c
}
}
}
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
if s.userCache == nil {
return
}
if err := s.userCache.Delete(ctx, []int64{botUserID}); err != nil {
s.log.Warn("invalidate bot user cache", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
}
func (s *Service) invalidateBotProfileCache(botUserID int64) {
if s.cache != nil {
s.cache.delete(botUserID)
}
}
func (s *Service) invalidateBotReadCaches(ctx context.Context, botUserID int64) {
s.invalidateBotProfileCache(botUserID)
s.invalidateUserCache(ctx, botUserID)
}
// InvalidateBotProfileReadModel 供 ReadModelChangeListener 在 user_base 事件(bot 写会
// bump bot_info_version)时跨实例失效本进程 bot 资料缓存。
func (s *Service) InvalidateBotProfileReadModel(userID int64) {
if s == nil {
return
}
s.invalidateBotProfileCache(userID)
}
// FlushBotProfileReadModel 供 listener 重连时整表 flush,兜住断连窗口内丢失的 user_base 通知。
func (s *Service) FlushBotProfileReadModel() {
if s == nil || s.cache == nil {
return
}
s.cache.flush()
}
// SetRouterHooks 注入 rpc 层回调(router 创建后装配,与 P1 的
// SetLifecycleObserver 同款延迟注入)。
func (s *Service) SetRouterHooks(h RouterHooks) {
if s != nil {
s.hooks = h
}
}
// NewService 创建 bots 服务。
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
s := &Service{
users: users,
bots: bots,
messages: messages,
cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL),
log: zap.NewNop(),
now: time.Now,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) botProfile(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.BotProfile{}, false, nil
}
if s.cache != nil {
return s.cache.getOrLoad(ctx, botUserID, func() (domain.BotProfile, bool, error) {
return s.bots.GetBot(ctx, botUserID)
})
}
return s.bots.GetBot(ctx, botUserID)
}
func (s *Service) botProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
if s == nil || s.bots == nil || len(botUserIDs) == 0 {
return nil, nil
}
ids := uniqueBotUserIDs(botUserIDs)
if len(ids) == 0 {
return nil, nil
}
if s.cache == nil {
return s.loadBotProfiles(ctx, ids)
}
return s.cache.getMany(ctx, ids, s.loadBotProfiles)
}
func (s *Service) loadBotProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
if batch, ok := s.bots.(botBatchStore); ok {
return batch.GetBots(ctx, botUserIDs)
}
out := make(map[int64]domain.BotProfile)
for _, id := range uniqueBotUserIDs(botUserIDs) {
profile, found, err := s.bots.GetBot(ctx, id)
if err != nil {
return nil, err
}
if found {
out[id] = profile
}
}
return out, nil
}
// BotInfo 返回 bot 的元数据(userFull.bot_info hydrate 用)。
func (s *Service) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
return s.botProfile(ctx, botUserID)
}
type botBatchStore interface {
GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
}
// BotInfos 批量返回 bot 元数据,供频道 full info / participants 这类高频富化路径避免逐 bot 点查。
func (s *Service) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
return s.botProfiles(ctx, botUserIDs)
}
func uniqueBotUserIDs(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// CheckUsername 校验 bot username 语法与全局可见入口占用(users + public channels)。
func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username string) (bool, error) {
if s == nil || s.users == nil || ownerUserID == 0 {
return false, domain.ErrBotUsernameInvalid
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if !domain.ValidBotUsername(username) {
return false, domain.ErrBotUsernameInvalid
}
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
return false, err
} else if found {
return false, nil
}
if s.channels != nil {
if _, found, err := s.channels.ResolvePublicChannelUsername(ctx, ownerUserID, username); err != nil {
return false, err
} else if found {
return false, nil
}
}
return true, nil
}
// CreateBot 创建一个新 bot 账号:users 行(is_bot, bot_info_version=1, 无 phone)+
// bots 行(owner、token)。返回新账号与完整 token(唯一一次返回明文的途径之一)。
func (s *Service) CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) {
if s == nil || s.users == nil || s.bots == nil || ownerUserID == 0 {
return domain.User{}, "", domain.ErrBotNameInvalid
}
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > domain.MaxBotNameLength {
return domain.User{}, "", domain.ErrBotNameInvalid
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if !domain.ValidBotUsername(username) {
return domain.User{}, "", domain.ErrBotUsernameInvalid
}
ok, err := s.CheckUsername(ctx, ownerUserID, username)
if err != nil {
return domain.User{}, "", err
}
if !ok {
return domain.User{}, "", domain.ErrUsernameOccupied
}
count, err := s.bots.CountBotsByOwner(ctx, ownerUserID)
if err != nil {
return domain.User{}, "", err
}
if count >= domain.MaxBotsPerOwner {
return domain.User{}, "", domain.ErrBotsTooMany
}
accessHash, err := randomInt64()
if err != nil {
return domain.User{}, "", err
}
secret, err := randomTokenSecret()
if err != nil {
return domain.User{}, "", err
}
u, profile, err := s.bots.CreateBotAccount(ctx, domain.User{
AccessHash: accessHash,
FirstName: name,
Username: username,
Bot: true,
BotInfoVersion: 1,
}, domain.BotProfile{
OwnerUserID: ownerUserID,
TokenSecret: secret,
})
if err != nil {
return domain.User{}, "", err
}
if s.cache != nil {
s.cache.put(u.ID, profile, true)
}
return u, domain.FormatBotToken(u.ID, secret), nil
}
// ListOwnedBots 返回当前 owner 管理的 bot 用户列表(排除 BotFather 种子)。
func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error) {
owned, err := s.ownedBots(ctx, ownerUserID)
if err != nil {
return nil, err
}
out := make([]domain.User, 0, len(owned))
for _, item := range owned {
out = append(out, item.user)
}
return out, nil
}
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
if revoke {
return s.RevokeBotToken(ctx, ownerUserID, botUserID)
}
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID || profile.TokenSecret == "" {
return "", domain.ErrBotNotFound
}
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
}
// RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有
// 已凭旧 token 登录的 session(经注入的 SessionRevoker)。
func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID {
return "", domain.ErrBotNotFound
}
secret, err := randomTokenSecret()
if err != nil {
return "", err
}
if err := s.bots.UpdateBotTokenSecret(ctx, botUserID, secret); err != nil {
return "", err
}
s.invalidateBotProfileCache(botUserID)
token := domain.FormatBotToken(botUserID, secret)
// 撤销已登录 session:旧 token 已不可重新登录,但已建立的连接仍持有 auth_key,
// 必须主动失效(删 authorization + 断连),否则旧持有者继续以 bot 身份操作。
// secret 已轮换不可回滚,故失败时仍返回新 token,但透出 ErrBotSessionsNotRevoked
// 让调用方诚实告知用户「需重试以确保旧 session 终止」,绝不谎称已止血。
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return token, domain.ErrBotSessionsNotRevoked
}
}
return token, nil
}
// SetBotCommands 覆盖式写入 bot 的 default scope 命令(bots.setBotCommands /
// BotFather /setcommands 共用收口)。校验命令名/描述/数量;写库(含 version bump)
// 成功后给在线相关用户推 updateBotCommands。返回 bump 后的 bot_info_version。
func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
if len(commands) > domain.MaxBotCommands {
return 0, domain.ErrBotCommandInvalid
}
clean := make([]domain.BotCommand, 0, len(commands))
for _, c := range commands {
cmd := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(c.Command, "/")))
desc := strings.TrimSpace(c.Description)
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
return 0, domain.ErrBotCommandInvalid
}
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc})
}
// 同值短路:bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
// bot_info_version bump(驱动全体客户端多打一轮 getFullUser)与多余推送。
// 非原子(读后他写不影响正确性:要么对方已 bump、要么我们多 bump 一次)。
cur, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return 0, err
}
if !found {
return 0, domain.ErrBotNotFound
}
if botCommandsEqual(cur.Commands, clean) {
return 0, nil // 无变更;调用方忽略返回的 version
}
version, err := s.bots.UpdateBotCommands(ctx, botUserID, clean)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
if s.hooks != nil {
s.hooks.PushBotCommandsChanged(ctx, botUserID, clean)
}
return version, nil
}
func botCommandsEqual(a, b []domain.BotCommand) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Command != b[i].Command || a[i].Description != b[i].Description {
return false
}
}
return true
}
// GetBotCommands 返回 bot 的 default scope 命令。
func (s *Service) GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return nil, err
}
if !found {
return nil, domain.ErrBotNotFound
}
return profile.Commands, nil
}
// SetBotInfo 更新 bot 的 name(users.first_name)/about(users.about)/description
// (bots.description),返回 bump 后的 bot_info_version。
func (s *Service) SetBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
if upd.SetName {
upd.Name = strings.TrimSpace(upd.Name)
if upd.Name == "" || utf8.RuneCountInString(upd.Name) > domain.MaxBotNameLength {
return 0, domain.ErrBotInfoInvalid
}
}
if upd.SetAbout && utf8.RuneCountInString(upd.About) > domain.MaxBotAboutLen {
return 0, domain.ErrBotInfoInvalid
}
if upd.SetDescription && utf8.RuneCountInString(upd.Description) > domain.MaxBotDescriptionLen {
return 0, domain.ErrBotInfoInvalid
}
if !upd.SetName && !upd.SetAbout && !upd.SetDescription {
return 0, domain.ErrBotInfoInvalid
}
version, err := s.bots.UpdateBotInfo(ctx, botUserID, upd)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// GetBotInfo 返回 bot 的 name/about/description(name=users.first_name、
// about=users.about、description=bots.description)。
func (s *Service) GetBotInfo(ctx context.Context, botUserID int64) (name, about, description string, err error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", "", "", err
}
if !found {
return "", "", "", domain.ErrBotNotFound
}
u, found, err := s.users.ByID(ctx, botUserID)
if err != nil {
return "", "", "", err
}
if !found {
return "", "", "", domain.ErrBotNotFound
}
return u.FirstName, u.About, profile.Description, nil
}
// SetBotMenuButton 设置 bot 的 menu button(per-bot 全局),返回新 bot_info_version。
func (s *Service) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
switch button.Type {
case domain.BotMenuButtonDefault, domain.BotMenuButtonCommands:
button.Text, button.URL = "", ""
case domain.BotMenuButtonWebView:
button.Text = strings.TrimSpace(button.Text)
button.URL = strings.TrimSpace(button.URL)
if button.Text == "" || len(button.Text) > domain.MaxBotMenuButtonTextLen ||
button.URL == "" || len(button.URL) > domain.MaxBotMenuButtonURLLen {
return 0, domain.ErrBotMenuButtonInvalid
}
// 强制 https(对齐官方 BUTTON_URL_INVALID):menu button URL 经
// userFull.bot_info.menu_button 下发给所有交互用户的客户端 webview 入口,
// 拒绝 javascript:/file:/intent: 等非 https scheme,防 bot 投毒。
if u, err := url.Parse(button.URL); err != nil || u.Scheme != "https" || u.Host == "" {
return 0, domain.ErrBotMenuButtonInvalid
}
default:
return 0, domain.ErrBotMenuButtonInvalid
}
version, err := s.bots.UpdateBotMenuButton(ctx, botUserID, button)
if err != nil {
return 0, err
}
if button.Type == domain.BotMenuButtonWebView {
if _, _, err := s.EnsureMenuBotApp(ctx, botUserID, button); err != nil {
return 0, err
}
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// GetBotMenuButton 返回 bot 的 menu button。
func (s *Service) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return domain.BotMenuButton{}, err
}
if !found {
return domain.BotMenuButton{}, domain.ErrBotNotFound
}
return profile.MenuButton, nil
}
// SetInlinePlaceholder 设置 inline mode placeholder;空字符串表示关闭 inline mode。
func (s *Service) SetInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) {
placeholder = strings.TrimSpace(placeholder)
if utf8.RuneCountInString(placeholder) > domain.MaxBotInlinePlaceholderLen {
return 0, domain.ErrBotInlinePlaceholderInvalid
}
version, err := s.bots.SetBotInlinePlaceholder(ctx, botUserID, placeholder)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetInlineGeo 设置 bot 是否可在 inline query 中接收用户位置。
func (s *Service) SetInlineGeo(ctx context.Context, botUserID int64, enabled bool) (int, error) {
version, err := s.bots.SetBotInlineGeo(ctx, botUserID, enabled)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetJoinGroups 设置 bot 能否被加入群组(allow=true → bot_nochats=false)。
func (s *Service) SetJoinGroups(ctx context.Context, botUserID int64, allow bool) (int, error) {
version, err := s.bots.SetBotNochats(ctx, botUserID, !allow)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// SetPrivacy 设置 bot 群内 privacy mode(enabled=true 隐私模式开 → bot_chat_history=false,
// 即 bot 只看命令/回复;enabled=false → 关闭隐私 → bot_chat_history=true,能看全部消息)。
func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool) (int, error) {
version, err := s.bots.SetBotChatHistory(ctx, botUserID, !enabled)
if err != nil {
return 0, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return version, nil
}
// CanSendMessage reports whether botUserID has explicit permission to initiate
// direct messages with userID.
func (s *Service) CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) {
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
return false, nil
}
return s.bots.CanBotSendMessage(ctx, botUserID, userID)
}
// AllowSendMessage records an explicit user grant for botUserID to message userID.
func (s *Service) AllowSendMessage(ctx context.Context, userID, botUserID int64, fromRequest bool) (bool, error) {
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
return false, domain.ErrBotNotFound
}
return s.bots.AllowBotSendMessage(ctx, botUserID, userID, fromRequest)
}
// OwnsBot 报告 ownerUserID 是否为 botUserID 的 owner(非 BotFather 自身)。
func (s *Service) OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return false, err
}
return found && profile.OwnerUserID == ownerUserID && botUserID != domain.BotFatherUserID, nil
}
// tokenSecretAlphabet 对齐官方 token 随机段字符集。
const tokenSecretAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
func randomTokenSecret() (string, error) {
raw := make([]byte, domain.BotTokenSecretLength)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
out := make([]byte, len(raw))
for i, b := range raw {
out[i] = tokenSecretAlphabet[int(b)%len(tokenSecretAlphabet)]
}
return string(out), nil
}
func randomInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("rand: %w", err)
}
v := int64(uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]))
return v, nil
}

View file

@ -0,0 +1,438 @@
package bots
import (
"context"
"fmt"
"regexp"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
return NewService(users, bots, messages), users, bots, messages
}
func newOwner(t *testing.T, users *memory.UserStore, phone string) domain.User {
t.Helper()
u, err := users.Create(context.Background(), domain.User{AccessHash: 1, Phone: phone, FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
return u
}
// sendToBotFather 同步驱动 responder(绕过 OnPrivateMessage 的 goroutine 派发以
// 保证单测确定性;异步派发由 mtprotoedge bot e2e 覆盖),返回 BotFather 最新回复文本。
func sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
t.Helper()
ctx := context.Background()
svc.respondAsBotFather(owner.ID, text)
list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Limit: 100,
})
if err != nil {
t.Fatalf("list history: %v", err)
}
var latest domain.Message
for _, msg := range list.Messages {
if msg.From.ID == domain.BotFatherUserID && msg.ID > latest.ID {
latest = msg
}
}
if latest.ID == 0 {
t.Fatalf("no BotFather reply after sending %q", text)
}
return latest.Body
}
var tokenRe = regexp.MustCompile(`(\d+):([A-Za-z0-9_-]{35})`)
func TestBotFatherNewBotFlow(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+1000")
ctx := context.Background()
if reply := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newbot") {
t.Fatalf("/start reply = %q, want help text", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "choose a name") {
t.Fatalf("/newbot reply = %q, want name prompt", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "My Test Bot"); !strings.Contains(reply, "username") {
t.Fatalf("name reply = %q, want username prompt", reply)
}
// 非法 username:不以 bot 结尾。
if reply := sendToBotFather(t, svc, messages, owner, "mytest"); !strings.Contains(reply, "invalid") {
t.Fatalf("invalid username reply = %q, want invalid notice", reply)
}
reply := sendToBotFather(t, svc, messages, owner, "my_test_bot")
match := tokenRe.FindStringSubmatch(reply)
if match == nil {
t.Fatalf("done reply = %q, want token", reply)
}
if !strings.Contains(reply, "telesrv.net/my_test_bot") {
t.Fatalf("done reply = %q, want deep link", reply)
}
created, found, err := users.ByUsername(ctx, "my_test_bot")
if err != nil || !found {
t.Fatalf("bot user not found: %v", err)
}
if !created.Bot || created.BotInfoVersion < 1 || created.Phone != "" {
t.Fatalf("bot user = %+v, want bot with bot_info_version>=1 and empty phone", created)
}
profile, found, err := bots.GetBot(ctx, created.ID)
if err != nil || !found {
t.Fatalf("bot profile not found: %v", err)
}
if profile.OwnerUserID != owner.ID {
t.Fatalf("bot owner = %d, want %d", profile.OwnerUserID, owner.ID)
}
if fmt.Sprintf("%d", created.ID) != match[1] || profile.TokenSecret != match[2] {
t.Fatalf("token %q does not match stored bot %d/%q", match[0], created.ID, profile.TokenSecret)
}
// 状态机已复位:普通文本回兜底提示。
if reply := sendToBotFather(t, svc, messages, owner, "hello"); !strings.Contains(reply, "/help") {
t.Fatalf("post-done reply = %q, want fallback", reply)
}
}
func TestBotProfileCacheCachesPositiveAndNegativeProfiles(t *testing.T) {
users := memory.NewUserStore()
botsStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
svc := NewService(users, botsStore, messages)
owner := newOwner(t, users, "+1090")
ctx := context.Background()
bot, _, err := svc.CreateBot(ctx, owner.ID, "Cache Bot", "cache_test_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
botsStore.reset()
if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found {
t.Fatalf("negative BotInfo = found %v err %v, want false,nil", found, err)
}
if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found {
t.Fatalf("second negative BotInfo = found %v err %v, want false,nil", found, err)
}
if botsStore.getBotCalls != 1 {
t.Fatalf("negative GetBot calls = %d, want 1", botsStore.getBotCalls)
}
botsStore.reset()
if profile, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found || profile.BotUserID != bot.ID {
t.Fatalf("cached positive BotInfo = profile %+v found %v err %v", profile, found, err)
}
if _, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found {
t.Fatalf("second positive BotInfo = found %v err %v, want true,nil", found, err)
}
if botsStore.getBotCalls != 0 {
t.Fatalf("positive GetBot calls after create prewarm = %d, want 0", botsStore.getBotCalls)
}
botsStore.reset()
profiles, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243, 424243})
if err != nil {
t.Fatalf("batch BotInfos: %v", err)
}
if len(profiles) != 1 || profiles[bot.ID].BotUserID != bot.ID {
t.Fatalf("batch profiles = %+v, want only bot", profiles)
}
if botsStore.getBotsCalls != 1 {
t.Fatalf("batch GetBots calls = %d, want 1 for new miss", botsStore.getBotsCalls)
}
if _, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243}); err != nil {
t.Fatalf("second batch BotInfos: %v", err)
}
if botsStore.getBotsCalls != 1 {
t.Fatalf("second batch GetBots calls = %d, want still 1", botsStore.getBotsCalls)
}
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "start", Description: "begin"}}); err != nil {
t.Fatalf("set commands: %v", err)
}
botsStore.reset()
profile, found, err := svc.BotInfo(ctx, bot.ID)
if err != nil || !found {
t.Fatalf("BotInfo after invalidation = found %v err %v", found, err)
}
if len(profile.Commands) != 1 || profile.Commands[0].Command != "start" {
t.Fatalf("commands after invalidation = %+v, want [start]", profile.Commands)
}
if botsStore.getBotCalls != 1 {
t.Fatalf("GetBot calls after invalidation = %d, want 1", botsStore.getBotCalls)
}
}
type countingBotStore struct {
*memory.BotStore
getBotCalls int
getBotsCalls int
}
func (s *countingBotStore) reset() {
s.getBotCalls = 0
s.getBotsCalls = 0
}
func (s *countingBotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
s.getBotCalls++
return s.BotStore.GetBot(ctx, botUserID)
}
func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
s.getBotsCalls++
return s.BotStore.GetBots(ctx, botUserIDs)
}
func TestBotFatherCancelAndUnknown(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1001")
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "No active command") {
t.Fatalf("idle /cancel reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/newbot")
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "cancelled") {
t.Fatalf("active /cancel reply = %q", reply)
}
// 取消后名字输入不再被当作 newbot 步骤。
if reply := sendToBotFather(t, svc, messages, owner, "Some Name"); !strings.Contains(reply, "/help") {
t.Fatalf("post-cancel reply = %q, want fallback", reply)
}
if reply := sendToBotFather(t, svc, messages, owner, "/definitelynotacommand"); !strings.Contains(reply, "Unrecognized") {
t.Fatalf("unknown command reply = %q", reply)
}
}
func TestBotFatherUsernameTaken(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1002")
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "First", "taken_bot"); err != nil {
t.Fatalf("seed first bot: %v", err)
}
sendToBotFather(t, svc, messages, owner, "/newbot")
sendToBotFather(t, svc, messages, owner, "Second")
if reply := sendToBotFather(t, svc, messages, owner, "taken_bot"); !strings.Contains(reply, "already taken") {
t.Fatalf("taken username reply = %q", reply)
}
// 状态保留:可继续尝试新 username。
if reply := sendToBotFather(t, svc, messages, owner, "second_bot"); !strings.Contains(reply, "telesrv.net/second_bot") {
t.Fatalf("retry username reply = %q", reply)
}
}
type usernameResolverStub struct {
taken string
}
func (s usernameResolverStub) ResolvePublicChannelUsername(_ context.Context, _ int64, username string) (domain.Channel, bool, error) {
return domain.Channel{}, strings.EqualFold(username, s.taken), nil
}
func TestCheckUsernameRejectsUserAndPublicChannelCollision(t *testing.T) {
users := memory.NewUserStore()
botsStore := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
svc := NewService(users, botsStore, messages, WithPublicChannelUsernameResolver(usernameResolverStub{taken: "channel_bot"}))
owner := newOwner(t, users, "+1012")
ctx := context.Background()
if ok, err := svc.CheckUsername(ctx, owner.ID, "fresh_bot"); err != nil || !ok {
t.Fatalf("fresh username = %v,%v, want true,nil", ok, err)
}
if _, _, err := svc.CreateBot(ctx, owner.ID, "Taken", "user_taken_bot"); err != nil {
t.Fatalf("seed bot: %v", err)
}
if ok, err := svc.CheckUsername(ctx, owner.ID, "user_taken_bot"); err != nil || ok {
t.Fatalf("user collision = %v,%v, want false,nil", ok, err)
}
if ok, err := svc.CheckUsername(ctx, owner.ID, "channel_bot"); err != nil || ok {
t.Fatalf("channel collision = %v,%v, want false,nil", ok, err)
}
if _, _, err := svc.CreateBot(ctx, owner.ID, "Channel Collision", "channel_bot"); err != domain.ErrUsernameOccupied {
t.Fatalf("create channel collision err = %v, want ErrUsernameOccupied", err)
}
if _, err := svc.CheckUsername(ctx, owner.ID, "notvalid"); err != domain.ErrBotUsernameInvalid {
t.Fatalf("invalid username err = %v, want ErrBotUsernameInvalid", err)
}
}
func TestBotFatherTokenAndRevoke(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+1003")
ctx := context.Background()
created, token, err := svc.CreateBot(ctx, owner.ID, "Token Bot", "tok_test_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
sendToBotFather(t, svc, messages, owner, "/token")
if reply := sendToBotFather(t, svc, messages, owner, "@tok_test_bot"); !strings.Contains(reply, token) {
t.Fatalf("/token reply = %q, want current token %q", reply, token)
}
sendToBotFather(t, svc, messages, owner, "/revoke")
reply := sendToBotFather(t, svc, messages, owner, "tok_test_bot")
match := tokenRe.FindStringSubmatch(reply)
if match == nil {
t.Fatalf("/revoke reply = %q, want new token", reply)
}
newToken := match[0]
if newToken == token {
t.Fatalf("revoke kept old token %q", token)
}
profile, _, err := bots.GetBot(ctx, created.ID)
if err != nil {
t.Fatalf("get bot: %v", err)
}
if domain.FormatBotToken(created.ID, profile.TokenSecret) != newToken {
t.Fatalf("stored secret %q does not match revoked token %q", profile.TokenSecret, newToken)
}
// 选择不属于自己的 bot。
sendToBotFather(t, svc, messages, owner, "/token")
if reply := sendToBotFather(t, svc, messages, owner, "@nosuch_bot"); !strings.Contains(reply, "don't see that bot") {
t.Fatalf("unknown choose reply = %q", reply)
}
}
func TestBotFatherMyBotsAndLimit(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1004")
ctx := context.Background()
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "don't have any bots") {
t.Fatalf("empty /mybots reply = %q", reply)
}
for i := 0; i < domain.MaxBotsPerOwner; i++ {
if _, _, err := svc.CreateBot(ctx, owner.ID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("limit%d_bot", i)); err != nil {
t.Fatalf("create bot %d: %v", i, err)
}
}
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "@limit0_bot") {
t.Fatalf("/mybots reply = %q, want bot list", reply)
}
if _, _, err := svc.CreateBot(ctx, owner.ID, "One Too Many", "toomany_bot"); err != domain.ErrBotsTooMany {
t.Fatalf("create over limit err = %v, want ErrBotsTooMany", err)
}
if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "limit") {
t.Fatalf("over-limit /newbot reply = %q", reply)
}
}
func TestParseBotCommand(t *testing.T) {
cases := []struct {
in string
cmd string
ok bool
}{
{"/newbot", "newbot", true},
{"/NewBot@BotFather", "newbot", true},
{"/token extra args", "token", true},
{"plain text", "", false},
{"/", "", false},
}
for _, tc := range cases {
cmd, ok := parseBotCommand(tc.in)
if cmd != tc.cmd || ok != tc.ok {
t.Errorf("parseBotCommand(%q) = %q,%v want %q,%v", tc.in, cmd, ok, tc.cmd, tc.ok)
}
}
}
type stubBlocker struct {
blocked bool
gotUser int64
gotPeer int64
}
func (s *stubBlocker) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) {
s.gotUser, s.gotPeer = userID, blockedUserID
return s.blocked, nil
}
func TestBotFatherReplyRespectsBlock(t *testing.T) {
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
blocker := &stubBlocker{blocked: true}
svc := NewService(users, bots, messages, WithBlockChecker(blocker))
owner := newOwner(t, users, "+1099")
ctx := context.Background()
svc.respondAsBotFather(owner.ID, "/help")
// IsBlocked 参数语义:owner(userID) 是否 block 了 BotFather(blockedUserID)。
if blocker.gotUser != owner.ID || blocker.gotPeer != domain.BotFatherUserID {
t.Fatalf("IsBlocked called with (user=%d, peer=%d), want (%d, %d)", blocker.gotUser, blocker.gotPeer, owner.ID, domain.BotFatherUserID)
}
// 被 block:回复不投递到 owner 收件箱。
list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Limit: 10,
})
if err != nil {
t.Fatalf("list history: %v", err)
}
for _, msg := range list.Messages {
if msg.From.ID == domain.BotFatherUserID {
t.Fatalf("blocked owner received BotFather reply: %q", msg.Body)
}
}
// 未 block:回复正常投递。
blocker.blocked = false
other := newOwner(t, users, "+1098")
svc.respondAsBotFather(other.ID, "/help")
otherList, err := messages.ListByUser(ctx, other.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Limit: 10,
})
if err != nil {
t.Fatalf("list other history: %v", err)
}
delivered := false
for _, msg := range otherList.Messages {
if msg.From.ID == domain.BotFatherUserID {
delivered = true
}
}
if !delivered {
t.Fatal("unblocked user did not receive BotFather reply")
}
}
func TestValidBotUsername(t *testing.T) {
valid := []string{"my_bot", "TetrisBot", "a1234bot", "x_bot_BOT"}
invalid := []string{"bot", "abot", "1abcbot", "_abcbot", "has space bot", "endsinbo", strings.Repeat("a", 30) + "bot" + "x"}
for _, u := range valid {
if !domain.ValidBotUsername(u) {
t.Errorf("ValidBotUsername(%q) = false, want true", u)
}
}
for _, u := range invalid {
if domain.ValidBotUsername(u) {
t.Errorf("ValidBotUsername(%q) = true, want false", u)
}
}
}

View file

@ -0,0 +1,91 @@
package channels
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
defaultActiveChannelIDsReadModelTTL = 24 * time.Hour
activeChannelIDsReadModelMaxEntries = 8192
activeChannelIDsNoVersionHash = -1
)
type activeChannelIDsCacheKey struct {
userID int64
afterChannelID int64
limit int
}
// activeChannelIDsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
// 无版本(version 缺失)时用 activeChannelIDsNoVersionHash 哨兵作版本,仍享 TTL+epoch 失效。
type activeChannelIDsReadModelCache struct {
cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64]
}
func newActiveChannelIDsReadModelCache(ttl time.Duration) *activeChannelIDsReadModelCache {
if ttl <= 0 {
ttl = defaultActiveChannelIDsReadModelTTL
}
return &activeChannelIDsReadModelCache{
cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{
MaxEntries: activeChannelIDsReadModelMaxEntries,
TTL: ttl,
Clone: cloneInt64s,
}),
}
}
func (c *activeChannelIDsReadModelCache) getOrLoad(ctx context.Context, key activeChannelIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) {
if c == nil || len(userIDs) == 0 {
return
}
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID != 0 {
seen[userID] = struct{}{}
}
}
if len(seen) == 0 {
return
}
c.cache.InvalidateWhere(func(k activeChannelIDsCacheKey) bool {
_, ok := seen[k.userID]
return ok
})
}
func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
if s.activeIDsCache == nil || s.versions == nil {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID)
if err != nil {
return nil, err
}
if !ok || hash == 0 {
hash = activeChannelIDsNoVersionHash
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
})
}
func cloneInt64s(in []int64) []int64 {
if len(in) == 0 {
return nil
}
return append([]int64(nil), in...)
}

View file

@ -0,0 +1,311 @@
package channels
import (
"context"
"sort"
"strings"
"telesrv/internal/domain"
)
// BotProfileResolver is the domain-only view of bot metadata used by channel policy.
type BotProfileResolver interface {
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
}
type botProfileBatchResolver interface {
BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
}
type activeChannelBotMemberLister interface {
ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error)
}
type activeChannelBotMemberIDLister interface {
ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
}
func (s *Service) getBotParticipants(ctx context.Context, userID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) {
if lister, ok := s.channels.(activeChannelBotMemberLister); ok {
return lister.ListActiveChannelBotMembers(ctx, userID, channelID, offset, limit)
}
channel, viewer, active, err := s.channels.ListActiveChannelMembers(ctx, userID, channelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
return domain.ChannelParticipantList{}, err
}
if channel.ParticipantsHidden && !channelServiceMemberIsAdmin(viewer) {
return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil
}
if offset < 0 {
offset = 0
}
if offset > domain.MaxChannelParticipantsOffset {
offset = domain.MaxChannelParticipantsOffset
}
ids := make([]int64, 0, len(active))
for _, member := range active {
if member.UserID != 0 {
ids = append(ids, member.UserID)
}
}
profiles, err := s.botProfiles(ctx, ids)
if err != nil {
return domain.ChannelParticipantList{}, err
}
members := make([]domain.ChannelMember, 0, limit)
count := 0
for _, member := range active {
if _, found := profiles[member.UserID]; !found {
continue
}
if count >= offset && len(members) < limit {
members = append(members, member)
}
count++
}
return domain.ChannelParticipantList{Channel: channel, Participants: members, Count: count}, nil
}
func (s *Service) botProfiles(ctx context.Context, ids []int64) (map[int64]domain.BotProfile, error) {
if len(ids) == 0 || s.bots == nil {
return nil, nil
}
if batch, ok := s.bots.(botProfileBatchResolver); ok {
return batch.BotInfos(ctx, ids)
}
out := make(map[int64]domain.BotProfile)
for _, id := range uniqueNonZero(ids) {
profile, found, err := s.bots.BotInfo(ctx, id)
if err != nil {
return nil, err
}
if found {
out[id] = profile
}
}
return out, nil
}
func (s *Service) rejectBlockedBotInvites(ctx context.Context, userIDs []int64) error {
if s.bots == nil {
return nil
}
for _, id := range uniqueNonZero(userIDs) {
profile, found, err := s.bots.BotInfo(ctx, id)
if err != nil {
return err
}
if found && profile.Nochats {
return domain.ErrBotGroupsBlocked
}
}
return nil
}
func (s *Service) skippedBotDeliveryUserIDs(ctx context.Context, req domain.SendChannelMessageRequest) ([]int64, error) {
if s.bots == nil || req.ChannelID == 0 || req.UserID == 0 {
return nil, nil
}
if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok {
memberIDs, err := lister.ListActiveChannelBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
return nil, err
}
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
}
memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
return nil, err
}
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
}
func (s *Service) skippedBotDeliveryUserIDsForIDs(ctx context.Context, req domain.SendChannelMessageRequest, memberIDs []int64) ([]int64, error) {
profiles, err := s.botProfiles(ctx, memberIDs)
if err != nil {
return nil, err
}
msg := domain.ChannelMessage{
ChannelID: req.ChannelID,
SenderUserID: req.UserID,
Body: req.Message,
ReplyTo: req.ReplyTo,
Action: req.Action,
}
skip := make([]int64, 0)
for _, id := range memberIDs {
if id == req.UserID {
continue
}
profile, found := profiles[id]
if !found || profile.ChatHistory {
continue
}
visible, err := s.botCanSeeChannelMessage(ctx, id, msg, req.MentionUserIDs)
if err != nil {
return nil, err
}
if !visible {
skip = append(skip, id)
}
}
return skip, nil
}
func (s *Service) filterBotChannelHistory(ctx context.Context, userID int64, history domain.ChannelHistory) domain.ChannelHistory {
if s.bots == nil || userID == 0 || history.Channel.ID == 0 {
return history
}
profile, found, err := s.bots.BotInfo(ctx, userID)
if err != nil || !found || profile.ChatHistory {
return history
}
filtered := history
filtered.Messages = make([]domain.ChannelMessage, 0, len(history.Messages))
for _, msg := range history.Messages {
if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible {
filtered.Messages = append(filtered.Messages, msg)
}
}
filtered.Count = len(filtered.Messages)
filtered.Users = nil
filtered.Channels = nil
return filtered
}
func (s *Service) filterBotChannelDifference(ctx context.Context, userID int64, diff domain.ChannelDifference) domain.ChannelDifference {
if s.bots == nil || userID == 0 || diff.Channel.ID == 0 {
return diff
}
profile, found, err := s.bots.BotInfo(ctx, userID)
if err != nil || !found || profile.ChatHistory {
return diff
}
filtered := diff
filtered.NewMessages = nil
filtered.OtherUpdates = nil
filtered.Events = nil
filtered.Users = nil
filtered.Channels = nil
if diff.TooLong {
for _, msg := range diff.NewMessages {
if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible {
filtered.NewMessages = append(filtered.NewMessages, msg)
}
}
return filtered
}
for _, event := range diff.Events {
visibleEvent, ok := s.filterBotChannelEvent(ctx, userID, event)
if !ok {
continue
}
filtered.Events = append(filtered.Events, visibleEvent)
switch visibleEvent.Type {
case domain.ChannelUpdateNewMessage:
filtered.NewMessages = append(filtered.NewMessages, visibleEvent.Message)
default:
filtered.OtherUpdates = append(filtered.OtherUpdates, visibleEvent)
}
}
return filtered
}
func (s *Service) filterBotChannelEvent(ctx context.Context, botUserID int64, event domain.ChannelUpdateEvent) (domain.ChannelUpdateEvent, bool) {
switch event.Type {
case domain.ChannelUpdateNewMessage, domain.ChannelUpdateEditMessage:
if event.Message.ID == 0 {
return event, true
}
visible, err := s.botCanSeeChannelMessage(ctx, botUserID, event.Message, nil)
if err != nil || !visible {
return domain.ChannelUpdateEvent{}, false
}
return event, true
case domain.ChannelUpdateDeleteMessages:
// 删除事件只携带消息 id、不含任何内容,且在线推送路径(channels_updates 的
// channelDeleteMessagesUpdates→enqueueChannelFanout)本就对全体成员无差别投递删除。
// 若在此按可见性过滤,会因被删消息无法重取(GetChannelMessages 带 AND NOT deleted
// 恒返空)而把整条 delete 事件丢弃——privacy bot 经 getChannelDifference 补差时将
// 对所有删除失明(连它本可见消息的删除也收不到),客户端缓存残留"未删"态。故直接放行,
// 与在线推送行为一致(删除 id 不泄漏内容)。
return event, true
case domain.ChannelUpdatePinnedMessages:
if len(event.MessageIDs) == 0 {
return event, true
}
ids := make([]int, 0, len(event.MessageIDs))
for _, id := range event.MessageIDs {
history, err := s.channels.GetChannelMessages(ctx, botUserID, event.ChannelID, []int{id})
if err != nil || len(history.Messages) == 0 {
continue
}
visible, err := s.botCanSeeChannelMessage(ctx, botUserID, history.Messages[0], nil)
if err == nil && visible {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return domain.ChannelUpdateEvent{}, false
}
event.MessageIDs = ids
return event, true
default:
return event, true
}
}
func (s *Service) botCanSeeChannelMessage(ctx context.Context, botUserID int64, msg domain.ChannelMessage, mentionUserIDs []int64) (bool, error) {
if botUserID == 0 {
return true, nil
}
if msg.SenderUserID == botUserID {
return true, nil
}
if msg.Mentioned || containsInt64(mentionUserIDs, botUserID) {
return true, nil
}
if msg.Action != nil && containsInt64(msg.Action.UserIDs, botUserID) {
return true, nil
}
if messageIsCommand(msg.Body) {
return true, nil
}
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
history, err := s.channels.GetChannelMessages(ctx, botUserID, msg.ChannelID, []int{msg.ReplyTo.MessageID})
if err != nil {
return false, nil
}
for _, target := range history.Messages {
if target.SenderUserID == botUserID {
return true, nil
}
}
}
return false, nil
}
func messageIsCommand(message string) bool {
message = strings.TrimSpace(message)
return strings.HasPrefix(message, "/") && len(message) > 1
}
func containsInt64(ids []int64, target int64) bool {
for _, id := range ids {
if id == target {
return true
}
}
return false
}
func channelServiceMemberIsAdmin(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
}
func mergeSkippedUserIDs(a, b []int64) []int64 {
out := append(append([]int64(nil), a...), b...)
out = uniqueNonZero(out)
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}

View file

@ -0,0 +1,134 @@
package channels
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultMediaCountReadModelTTL = 24 * time.Hour
mediaCountReadModelMaxEntries = 8192
)
type mediaCountCacheKey struct {
userID int64
channelID int64
}
// mediaCountReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
type mediaCountReadModelCache struct {
cache *readmodelcache.Cache[mediaCountCacheKey, domain.MediaCategoryCounts]
}
func newMediaCountReadModelCache(ttl time.Duration) *mediaCountReadModelCache {
if ttl <= 0 {
ttl = defaultMediaCountReadModelTTL
}
return &mediaCountReadModelCache{
cache: readmodelcache.New[mediaCountCacheKey, domain.MediaCategoryCounts](readmodelcache.Config[mediaCountCacheKey, domain.MediaCategoryCounts]{
MaxEntries: mediaCountReadModelMaxEntries,
TTL: ttl,
Clone: cloneMediaCategoryCounts,
}),
}
}
func (c *mediaCountReadModelCache) getOrLoad(ctx context.Context, key mediaCountCacheKey, hash int64, load func() (domain.MediaCategoryCounts, error)) (domain.MediaCategoryCounts, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (c *mediaCountReadModelCache) invalidateChannel(channelID int64) {
if c == nil || c.cache == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key mediaCountCacheKey) bool {
return key.channelID == channelID
})
}
func (c *mediaCountReadModelCache) invalidateViewer(userID, channelID int64) {
if c == nil || c.cache == nil || userID == 0 || channelID == 0 {
return
}
c.cache.Invalidate(mediaCountCacheKey{userID: userID, channelID: channelID})
}
func (c *mediaCountReadModelCache) flush() {
if c == nil || c.cache == nil {
return
}
c.cache.Flush()
}
func (s *Service) InvalidateChannelMediaCountReadModel(channelID int64) {
if s == nil || s.mediaCountCache == nil {
return
}
s.mediaCountCache.invalidateChannel(channelID)
}
func (s *Service) InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) {
if s == nil || s.mediaCountCache == nil {
return
}
s.mediaCountCache.invalidateViewer(userID, channelID)
}
func (s *Service) FlushChannelMediaCountReadModel() {
if s == nil || s.mediaCountCache == nil {
return
}
s.mediaCountCache.flush()
}
func (s *Service) cachedChannelMediaCounts(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) {
if s.mediaCountCache == nil || s.versions == nil {
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
}
hash, err := s.channelMediaCountHash(ctx, userID, channelID)
if err != nil {
return nil, err
}
if hash == 0 {
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
}
key := mediaCountCacheKey{userID: userID, channelID: channelID}
return s.mediaCountCache.getOrLoad(ctx, key, hash, func() (domain.MediaCategoryCounts, error) {
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
})
}
func (s *Service) channelMediaCountHash(ctx context.Context, userID, channelID int64) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelMediaCounts, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
media := rows[keys[0]]
if media == 0 {
return 0, nil
}
return readmodel.MixHashes(media, rows[keys[1]]), nil
}
func cloneMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts {
if len(in) == 0 {
return domain.MediaCategoryCounts{}
}
out := make(domain.MediaCategoryCounts, len(in))
for category, count := range in {
out[category] = count
}
return out
}

View file

@ -0,0 +1,62 @@
package channels
import (
"context"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestChannelHistoryMusicOnlyFiltersAudioDocuments(t *testing.T) {
ctx := context.Background()
store := memory.NewChannelStore()
service := NewService(store)
created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
Title: "Music Filter",
Megagroup: true,
MemberUserIDs: []int64{1002},
Date: 10,
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
music := domain.Document{
ID: 301,
AccessHash: 3001,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, AudioDuration: 200, Title: "Channel Song"}},
}
voice := domain.Document{
ID: 302,
AccessHash: 3002,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 4}},
}
if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 1,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &voice, Voice: true},
Date: 11,
}); err != nil {
t.Fatalf("SendMessage voice: %v", err)
}
if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 2,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &music},
Date: 12,
}); err != nil {
t.Fatalf("SendMessage music: %v", err)
}
history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
MusicOnly: true,
Limit: 10,
})
if err != nil {
t.Fatalf("GetHistory music: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].Media == nil || history.Messages[0].Media.Document == nil || history.Messages[0].Media.Document.ID != music.ID {
t.Fatalf("music history = %+v, want only music document", history.Messages)
}
}

View file

@ -0,0 +1,160 @@
package channels
import (
"context"
"encoding/binary"
"hash/fnv"
"strings"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultParticipantsReadModelTTL = 30 * time.Minute
participantsReadModelMaxEntries = 8192
)
type participantsCacheKey struct {
userID int64
channelID int64
kind domain.ChannelParticipantsFilterKind
query string
offset int
limit int
}
// participantsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone)。
// LRU 终于给 (query,offset,limit) 维度的 page-key 上界,消掉原先无界 query-string 基数增长。
type participantsReadModelCache struct {
cache *readmodelcache.Cache[participantsCacheKey, domain.ChannelParticipantList]
}
func newParticipantsReadModelCache(ttl time.Duration) *participantsReadModelCache {
if ttl <= 0 {
ttl = defaultParticipantsReadModelTTL
}
return &participantsReadModelCache{
cache: readmodelcache.New[participantsCacheKey, domain.ChannelParticipantList](readmodelcache.Config[participantsCacheKey, domain.ChannelParticipantList]{
MaxEntries: participantsReadModelMaxEntries,
TTL: ttl,
Clone: cloneParticipantList,
}),
}
}
func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key participantsCacheKey, hash int64, load func() (domain.ChannelParticipantList, error)) (domain.ChannelParticipantList, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
if s.participantCache == nil || s.versions == nil {
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
}
key := participantsCacheKey{
userID: userID,
channelID: channelID,
kind: filter.Kind,
query: normalizeParticipantsQuery(filter.Query),
offset: offset,
limit: limit,
}
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
if err != nil {
return domain.ChannelParticipantList{}, err
}
if hash == 0 {
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
}
return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
if err != nil {
return domain.ChannelParticipantList{}, err
}
list.Hash = hash
return list, nil
})
}
func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
}
return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, limit)
}
func (s *Service) channelParticipantsHash(ctx context.Context, userID, channelID int64, key participantsCacheKey) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelContactAccount, OwnerUserID: userID, PeerType: domain.PeerTypeUser, PeerID: userID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
base := rows[keys[0]]
participants := rows[keys[1]]
if base == 0 || participants == 0 {
return 0, nil
}
return readmodel.MixHashes(base, participants, rows[keys[2]], rows[keys[3]], participantsPageHash(key)), nil
}
func normalizeParticipantsRequest(filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantsFilter, int, int) {
if filter.Kind == "" {
filter.Kind = domain.ChannelParticipantsRecent
}
filter.Query = normalizeParticipantsQuery(filter.Query)
if offset < 0 {
offset = 0
}
if offset > domain.MaxChannelParticipantsOffset {
offset = domain.MaxChannelParticipantsOffset
}
if limit <= 0 || limit > domain.MaxChannelParticipantsLimit {
limit = domain.MaxChannelParticipantsLimit
}
return filter, offset, limit
}
func normalizeParticipantsQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func participantsPageHash(key participantsCacheKey) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(key.kind))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(key.query))
var buf [16]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(key.offset))
binary.LittleEndian.PutUint64(buf[8:16], uint64(key.limit))
_, _ = h.Write(buf[:])
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}
func cloneParticipantList(in domain.ChannelParticipantList) domain.ChannelParticipantList {
in.Channel = cloneChannel(in.Channel)
in.Participants = append([]domain.ChannelMember(nil), in.Participants...)
if len(in.Users) > 0 {
in.Users = make([]domain.User, len(in.Users))
for i, user := range in.Users {
user.PhotoStripped = append([]byte(nil), user.PhotoStripped...)
in.Users[i] = user
}
}
return in
}

View file

@ -0,0 +1,107 @@
package channels
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
// Full channel views are version-token guarded by channel_base,
// channel_member, and dialog_light. Keep the snapshot long-lived; write-side
// read-model bumps, not time, drive correctness.
defaultChannelViewReadModelTTL = 24 * time.Hour
channelViewReadModelMaxEntries = 8192
)
type channelViewCacheKey struct {
userID int64
channelID int64
}
// channelViewReadModelCache 与 channelResolveReadModelCache 都缓存 domain.ChannelView,
// 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
type channelViewReadModelCache struct {
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
}
func newChannelViewReadModelCache(ttl time.Duration) *channelViewReadModelCache {
if ttl <= 0 {
ttl = defaultChannelViewReadModelTTL
}
return &channelViewReadModelCache{
cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{
MaxEntries: channelViewReadModelMaxEntries,
TTL: ttl,
Clone: cloneChannelView,
}),
}
}
func (c *channelViewReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (s *Service) cachedChannelView(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
if s.viewCache == nil || s.versions == nil {
return s.channels.GetChannel(ctx, userID, channelID)
}
hash, err := s.channelViewHash(ctx, userID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
if hash == 0 {
return s.channels.GetChannel(ctx, userID, channelID)
}
key := channelViewCacheKey{userID: userID, channelID: channelID}
return s.viewCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) {
return s.channels.GetChannel(ctx, userID, channelID)
})
}
func (s *Service) channelViewHash(ctx context.Context, userID, channelID int64) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelDialogLight, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
// 快照含 SelfBoostsApplied:必须把 channel_self_boosts 纳入校验 token,否则 apply/revoke
// 加成不会失效这份长 TTL 快照。注:boost 自然到期是 time-based、不触发写,故其残余仍受 TTL 约束。
{Model: readmodel.ModelChannelSelfBoosts, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
base := rows[keys[0]]
if base == 0 {
return 0, nil
}
return readmodel.MixHashes(base, rows[keys[1]], rows[keys[2]], rows[keys[3]]), nil
}
func cloneChannelView(in domain.ChannelView) domain.ChannelView {
in.Channel = cloneChannel(in.Channel)
if in.Dialog.DefaultSendAs != nil {
peer := *in.Dialog.DefaultSendAs
in.Dialog.DefaultSendAs = &peer
}
if in.ExportedInvite != nil {
invite := *in.ExportedInvite
in.ExportedInvite = &invite
}
return in
}
func cloneChannel(in domain.Channel) domain.Channel {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...)
in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...)
return in
}

View file

@ -0,0 +1,74 @@
package channels
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultChannelResolveReadModelTTL = 24 * time.Hour
channelResolveReadModelMaxEntries = 16384
)
// channelResolveReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
type channelResolveReadModelCache struct {
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
}
func newChannelResolveReadModelCache(ttl time.Duration) *channelResolveReadModelCache {
if ttl <= 0 {
ttl = defaultChannelResolveReadModelTTL
}
return &channelResolveReadModelCache{
cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{
MaxEntries: channelResolveReadModelMaxEntries,
TTL: ttl,
Clone: cloneChannelView,
}),
}
}
func (c *channelResolveReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (s *Service) cachedResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
if s.resolveCache == nil || s.versions == nil {
return s.channels.ResolveChannel(ctx, userID, channelID)
}
hash, err := s.channelResolveHash(ctx, userID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
if hash == 0 {
return s.channels.ResolveChannel(ctx, userID, channelID)
}
key := channelViewCacheKey{userID: userID, channelID: channelID}
return s.resolveCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) {
return s.channels.ResolveChannel(ctx, userID, channelID)
})
}
func (s *Service) channelResolveHash(ctx context.Context, userID, channelID int64) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
base := rows[keys[0]]
if base == 0 {
return 0, nil
}
return readmodel.MixHashes(base, rows[keys[1]]), nil
}

View file

@ -2,6 +2,7 @@ package channels
import (
"context"
"errors"
"strings"
"unicode/utf8"
@ -12,11 +13,57 @@ import (
// Service exposes channel/supergroup business operations.
type Service struct {
channels store.ChannelStore
bots BotProfileResolver
versions store.ReadModelVersionStore
sendGate SendPermissionChecker
viewCache *channelViewReadModelCache
resolveCache *channelResolveReadModelCache
mediaCountCache *mediaCountReadModelCache
participantCache *participantsReadModelCache
activeIDsCache *activeChannelIDsReadModelCache
}
type Option func(*Service)
type SendPermissionChecker interface {
CanSendMessages(ctx context.Context, userID int64) error
}
// NewService creates a channel service.
func NewService(channels store.ChannelStore) *Service {
return &Service{channels: channels}
func NewService(channels store.ChannelStore, opts ...Option) *Service {
s := &Service{
channels: channels,
viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
}
for _, opt := range opts {
opt(s)
}
return s
}
// WithBotProfileResolver enables group bot membership and privacy policy checks.
func WithBotProfileResolver(bots BotProfileResolver) Option {
return func(s *Service) {
s.bots = bots
}
}
// WithReadModelVersions enables version-token guarded channel full-view caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) {
s.versions = v
}
}
func WithSendPermissionChecker(c SendPermissionChecker) Option {
return func(s *Service) {
s.sendGate = c
}
}
// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup.
@ -44,10 +91,24 @@ func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.Cr
if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
if err := s.rejectBlockedBotInvites(ctx, req.MemberUserIDs); err != nil {
return domain.CreateChannelResult{}, err
}
if !req.Broadcast && !req.Megagroup {
req.Broadcast = true
}
return s.channels.CreateChannel(ctx, req)
res, err := s.channels.CreateChannel(ctx, req)
if err != nil {
return res, err
}
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(userID, res.Members)...)
// 官方语义:创建即生成创建者的永久主链接(DrKLO 建频道后立刻
// getExportedChatInvites 取 invites[0])。失败不阻断创建——
// ListExportedInvites 首页自愈会兜底补上。
if res.Channel.ID != 0 {
_, _ = s.channels.EnsurePermanentInvite(ctx, res.Channel.ID, userID, req.Date)
}
return res, nil
}
// GetChannel returns channel data personalized for userID.
@ -58,6 +119,67 @@ func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (doma
return s.channels.GetChannel(ctx, userID, channelID)
}
// GetChannelReadModel returns the full channel view through a version-token guarded
// read model cache. It is intended for read-only RPC projection paths, not write
// permission checks.
func (s *Service) GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
return s.cachedChannelView(ctx, userID, channelID)
}
// ResolveChannel 是 GetChannel 的轻量版(仅访问校验 + Channel/Self,跳过 dialog/boost 查询),
// 供只需 access_hash / 频道标志的 peer 解析路径用。访问语义与 GetChannel 一致。
func (s *Service) ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
return s.cachedResolveChannel(ctx, userID, channelID)
}
// SearchChannelMedia 返回某频道中属于给定媒体类别的消息(共享媒体标签页)。
func (s *Service) SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.ChannelHistory{}, domain.ErrChannelInvalid
}
return s.channels.SearchChannelMedia(ctx, userID, channelID, req)
}
// CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。
func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.MediaCategoryCounts{}, domain.ErrChannelInvalid
}
return s.cachedChannelMediaCounts(ctx, userID, channelID)
}
// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs.
func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, domain.ErrChannelInvalid
}
ids := uniqueNonZero(channelIDs)
if len(ids) == 0 {
return nil, nil
}
if s.versions != nil {
out := make([]domain.ChannelView, 0, len(ids))
for _, id := range ids {
view, err := s.GetChannelReadModel(ctx, userID, id)
if err != nil {
if errors.Is(err, domain.ErrChannelPrivate) || errors.Is(err, domain.ErrChannelInvalid) {
continue
}
return s.channels.GetChannels(ctx, userID, ids)
}
out = append(out, view)
}
return out, nil
}
return s.channels.GetChannels(ctx, userID, ids)
}
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
@ -66,6 +188,14 @@ func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int6
return s.channels.GetChannelByID(ctx, channelID)
}
// GetChannelByID returns the non-personalized channel base row for internal admin use.
func (s *Service) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.GetChannelByID(ctx, channelID)
}
// GetParticipants returns a bounded participants page.
func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
@ -74,7 +204,7 @@ func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64,
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
return domain.ChannelParticipantList{}, domain.ErrChannelInvalid
}
return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, capLimit(limit, domain.MaxChannelParticipantsLimit))
return s.cachedParticipants(ctx, userID, channelID, filter, offset, limit)
}
// GetParticipant returns one participant.
@ -85,6 +215,14 @@ func (s *Service) GetParticipant(ctx context.Context, userID, channelID, partici
return s.channels.GetParticipant(ctx, userID, channelID, participantUserID)
}
// FutureCreatorAfterLeave returns the member that will become creator if userID leaves.
func (s *Service) FutureCreatorAfterLeave(ctx context.Context, userID, channelID int64) (domain.ChannelMember, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.ChannelMember{}, domain.ErrChannelInvalid
}
return s.channels.FutureCreatorAfterLeave(ctx, channelID, userID)
}
// InviteToChannel invites users to a channel/supergroup.
func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(userIDs) == 0 {
@ -93,7 +231,14 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
if len(userIDs) > domain.MaxChannelInviteUsers {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
return s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
if err := s.rejectBlockedBotInvites(ctx, userIDs); err != nil {
return domain.CreateChannelResult{}, err
}
res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
if err == nil {
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
}
return res, err
}
// JoinChannel joins current user to a channel/supergroup.
@ -101,7 +246,11 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
return s.channels.JoinChannel(ctx, channelID, userID, date)
res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
if err == nil {
s.invalidateActiveChannelIDs(userID)
}
return res, err
}
// LeaveChannel leaves current user from a channel/supergroup.
@ -109,7 +258,11 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
return s.channels.LeaveChannel(ctx, channelID, userID, date)
res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
if err == nil {
s.invalidateActiveChannelIDs(userID)
}
return res, err
}
// EditTitle edits a channel/supergroup title.
@ -126,6 +279,20 @@ func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditCh
return s.channels.EditChannelTitle(ctx, req)
}
// SetWallpaper sets or clears the channel/supergroup wallpaper.
func (s *Service) SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) {
if s == nil || s.channels == nil || userID == 0 {
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.ChannelID == 0 {
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelWallpaper(ctx, req)
}
// EditAbout edits a channel/supergroup description.
func (s *Service) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -154,6 +321,21 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
return s.channels.EditChannelAdmin(ctx, req)
}
// EditMemberRank sets or clears a participant's member tag without touching
// their role or admin rights.
func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
if s == nil || s.channels == nil || userID == 0 {
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
}
return s.channels.EditChannelMemberRank(ctx, req)
}
// EditBanned edits a participant's banned rights.
func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -165,7 +347,11 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
if req.UserID != userID || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 {
return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
}
return s.channels.EditChannelBanned(ctx, req)
res, err := s.channels.EditChannelBanned(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(req.Participant.ID)
}
return res, err
}
// EditDefaultBannedRights edits the channel/supergroup default restrictions.
@ -193,7 +379,11 @@ func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.De
if req.UserID != userID || req.ChannelID == 0 {
return domain.DeleteChannelResult{}, domain.ErrChannelInvalid
}
return s.channels.DeleteChannel(ctx, req)
res, err := s.channels.DeleteChannel(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...)
}
return res, err
}
// CheckUsername checks whether a channel username is syntactically valid and free.
@ -226,6 +416,14 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.U
return s.channels.UpdateUsername(ctx, req)
}
// SetVerified sets or clears the channel/supergroup verified badge through the internal admin path.
func (s *Service) SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelVerified(ctx, channelID, verified)
}
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -234,6 +432,22 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) (
return s.channels.ListAdminedPublicChannels(ctx, userID)
}
// ListStoryPostableChannels returns channels where user can publish stories.
func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, nil
}
return s.channels.ListStoryPostableChannels(ctx, userID)
}
// ListSendAsChannels returns the broadcast channels the user may post messages as in groups.
func (s *Service) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, nil
}
return s.channels.ListSendAsChannels(ctx, userID)
}
// ResolvePublicUsername resolves a public channel/supergroup username visible to userID.
func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -318,9 +532,9 @@ func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID
}
// SetPaidMessagesPrice stores the currently advertised paid-message price state.
func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) {
func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 {
return domain.Channel{}, domain.ErrChannelInvalid
return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid
}
return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed)
}
@ -341,6 +555,15 @@ func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seco
return s.channels.SetSlowMode(ctx, userID, channelID, seconds)
}
// SetBoostsToUnblockRestrictions stores the boost threshold that lets boosted
// members bypass default send-message restrictions.
func (s *Service) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetBoostsToUnblockRestrictions(ctx, userID, channelID, boosts)
}
// SetNoForwards toggles channel/supergroup content protection.
func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
@ -349,6 +572,30 @@ func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, en
return s.channels.SetNoForwards(ctx, userID, channelID, enabled)
}
// SetHistoryTTL updates channel/supergroup message auto-delete period.
func (s *Service) SetHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || period < 0 {
return domain.Channel{}, nil, domain.ErrChannelInvalid
}
ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
if !ok {
return domain.Channel{}, nil, domain.ErrChannelInvalid
}
return ttl.SetChannelHistoryTTL(ctx, userID, channelID, period, date)
}
// ClaimExpiredMessages returns expired channel delete batches for the TTL worker.
func (s *Service) ClaimExpiredMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) {
if s == nil || s.channels == nil {
return nil, nil
}
ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
if !ok {
return nil, nil
}
return ttl.ClaimExpiredChannelMessages(ctx, now, limit)
}
// SetJoinToSend toggles whether non-members must join before sending in a megagroup.
func (s *Service) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
@ -370,9 +617,9 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionItems ||
if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionTypes ||
policy.Limit < 0 ||
policy.Limit > domain.MaxChannelReactionItems {
policy.Limit > domain.MaxChannelReactionsLimit {
return domain.Channel{}, domain.ErrChannelInvalid
}
for _, emoticon := range policy.Emoticons {
@ -380,6 +627,11 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i
return domain.Channel{}, domain.ErrChannelInvalid
}
}
for _, documentID := range policy.CustomEmojiIDs {
if documentID <= 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
}
return s.channels.SetAvailableReactions(ctx, userID, channelID, policy)
}
@ -402,6 +654,55 @@ func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, s
return s.channels.SetEmojiStatus(ctx, userID, channelID, status)
}
func (s *Service) GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 {
return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid
}
return s.channels.GetPremiumBoostStatus(ctx, userID, channelID, now)
}
func (s *Service) ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 || len(offset) > domain.MaxPremiumBoostsOffsetBytes {
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
}
if limit <= 0 {
limit = domain.MaxPremiumBoostsListLimit
}
if limit > domain.MaxPremiumBoostsListLimit {
limit = domain.MaxPremiumBoostsListLimit
}
return s.channels.ListPremiumBoosts(ctx, userID, channelID, gifts, offset, limit, now)
}
func (s *Service) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
if s == nil || s.channels == nil || userID == 0 || now < 0 || premiumUntil < 0 {
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
}
return s.channels.GetPremiumMyBoosts(ctx, userID, now, premiumUntil)
}
func (s *Service) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(slots) == 0 || len(slots) > domain.MaxPremiumBoostSlotsPerApply || now < 0 || premiumUntil < 0 {
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
}
for _, slot := range slots {
if slot <= 0 {
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
}
}
if premiumUntil <= now {
return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired
}
return s.channels.ApplyPremiumBoost(ctx, userID, channelID, slots, now, premiumUntil)
}
func (s *Service) GetPremiumUserBoosts(ctx context.Context, userID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || targetUserID == 0 || now < 0 {
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
}
return s.channels.GetPremiumUserBoosts(ctx, userID, channelID, targetUserID, now)
}
// ListAdminLog returns one bounded, channel-scoped admin log page.
func (s *Service) ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -432,6 +733,46 @@ func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID
return domain.ChannelView{}, domain.ErrChannelAdminRequired
}
// CanPostStory validates whether the current user can publish a channel story.
func (s *Service) CanPostStory(ctx context.Context, userID, channelID int64) error {
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
return rights.PostStories
})
}
// CanEditStory validates whether the current user can edit a channel story.
func (s *Service) CanEditStory(ctx context.Context, userID, channelID int64) error {
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
return rights.EditStories
})
}
// CanDeleteStory validates whether the current user can delete a channel story.
func (s *Service) CanDeleteStory(ctx context.Context, userID, channelID int64) error {
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
return rights.DeleteStories
})
}
// CanPinStory validates whether the current user can change channel story pin state.
func (s *Service) CanPinStory(ctx context.Context, userID, channelID int64) error {
return s.CanEditStory(ctx, userID, channelID)
}
func (s *Service) canManageStory(ctx context.Context, userID, channelID int64, allowed func(domain.ChannelAdminRights) bool) error {
view, err := s.GetChannel(ctx, userID, channelID)
if err != nil {
return err
}
if view.Self.Role == domain.ChannelRoleCreator {
return nil
}
if view.Self.Role == domain.ChannelRoleAdmin && allowed(view.Self.AdminRights) {
return nil
}
return domain.ErrChannelAdminRequired
}
// SaveDefaultSendAs persists the current user's default send-as peer for one channel/supergroup dialog.
func (s *Service) SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -482,6 +823,49 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
return s.channels.SetChannelMessageReactions(ctx, req)
}
// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本
// Debit 完成,本方法只负责累计与聚合。
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
return s.channels.AddChannelMessagePaidReaction(ctx, req)
}
// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
}
return s.channels.VoteChannelMessagePoll(ctx, req)
}
// CloseMessagePoll 关闭频道/超级群消息上的 poll(仅 poll 创建者)。
func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
}
return s.channels.CloseChannelMessagePoll(ctx, req)
}
// GetMessageReactions returns reaction summaries for exact channel/supergroup message ids.
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
@ -813,9 +1197,24 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send
if req.UserID != userID {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if err := s.ensureCanSend(ctx, req.UserID); err != nil {
return domain.SendChannelMessageResult{}, err
}
skipped, err := s.skippedBotDeliveryUserIDs(ctx, req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.SkipDeliveryUserIDs = mergeSkippedUserIDs(req.SkipDeliveryUserIDs, skipped)
return s.channels.SendChannelMessage(ctx, req)
}
func (s *Service) ensureCanSend(ctx context.Context, userID int64) error {
if s == nil || s.sendGate == nil || userID == 0 {
return nil
}
return s.sendGate.CanSendMessages(ctx, userID)
}
// EditMessage edits a channel/supergroup text message.
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -830,6 +1229,23 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
return s.channels.EditChannelMessage(ctx, req)
}
// GetInlineBotMessage returns one live channel message addressed by a signed inline id.
func (s *Service) GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
if s == nil || s.channels == nil || botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
return domain.Channel{}, domain.ChannelMessage{}, false, domain.ErrChannelInvalid
}
return s.channels.GetChannelMessageForInlineBot(ctx, botID, channelID, id)
}
// EditInlineBotMessage edits a channel message through its via-bot inline id.
func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
req.ViaBotEditBotID = botID
return s.channels.EditChannelMessage(ctx, req)
}
// DeleteMessages deletes a bounded set of channel/supergroup messages.
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -889,6 +1305,20 @@ func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req dom
return s.channels.UpdatePinnedMessage(ctx, req)
}
// UnpinAllMessages clears every pinned message in a channel/supergroup.
func (s *Service) UnpinAllMessages(ctx context.Context, userID int64, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) {
if s == nil || s.channels == nil || userID == 0 {
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.ChannelID == 0 {
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
}
return s.channels.UnpinAllChannelMessages(ctx, req)
}
// ExportInvite exports a channel/supergroup invite link.
func (s *Service) ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -926,7 +1356,11 @@ func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.Imp
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty
}
req.Hash = strings.TrimSpace(req.Hash)
return s.channels.ImportInvite(ctx, req)
res, err := s.channels.ImportInvite(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(userID)
}
return res, err
}
// ListExportedInvites returns a bounded invite management page.
@ -942,6 +1376,13 @@ func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req dom
}
req.OffsetHash = strings.TrimSpace(req.OffsetHash)
req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit)
// 官方语义自愈:管理员查看自己的有效链接首页时,永久主链接必须存在
//(存量频道/创建路径漏建借此补上;权限校验由 store 内部完成)。
if req.AdminUserID == userID && !req.Revoked && req.OffsetDate == 0 && req.OffsetHash == "" {
if _, err := s.channels.EnsurePermanentInvite(ctx, req.ChannelID, userID, 0); err != nil {
return domain.ChannelInviteList{}, err
}
}
return s.channels.ListExportedInvites(ctx, req)
}
@ -1056,7 +1497,11 @@ func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req dom
if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
return s.channels.HideChatJoinRequest(ctx, req)
res, err := s.channels.HideChatJoinRequest(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(req.TargetUserID, res.Members)...)
}
return res, err
}
// HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch.
@ -1072,7 +1517,11 @@ func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests)
return s.channels.HideAllChatJoinRequests(ctx, req)
res, err := s.channels.HideAllChatJoinRequests(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
}
return res, err
}
// ListDialogs returns current user's channel/supergroup dialog page.
@ -1180,7 +1629,11 @@ func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.Ch
return domain.ChannelHistory{}, domain.ErrChannelInvalid
}
filter.Limit = capLimit(filter.Limit, 100)
return s.channels.ListChannelHistory(ctx, userID, filter)
history, err := s.channels.ListChannelHistory(ctx, userID, filter)
if err != nil {
return domain.ChannelHistory{}, err
}
return s.filterBotChannelHistory(ctx, userID, history), nil
}
// SearchPosts returns a bounded page of public channel/supergroup posts.
@ -1223,7 +1676,7 @@ func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req do
return domain.ChannelHistory{}, nil
}
req.Query = strings.TrimSpace(req.Query)
if req.Query == "" {
if req.Query == "" && !req.MusicOnly {
return domain.ChannelHistory{}, domain.ErrChannelInvalid
}
if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength {
@ -1243,7 +1696,56 @@ func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.Ch
}
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
return s.channels.ListChannelReplies(ctx, userID, filter)
history, err := s.channels.ListChannelReplies(ctx, userID, filter)
if err != nil {
return domain.ChannelHistory{}, err
}
return s.filterBotChannelHistory(ctx, userID, history), nil
}
// SendMonoforumMessage 发送频道私信(monoforum)。发件权限(订阅者身份 / monoforum 管理员)
// 由 RPC 层校验,此处仅参数校验并委托 store(store 不要求发件人是 monoforum 成员)。
func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
if s == nil || s.channels == nil || req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
return domain.SendChannelMessageResult{}, err
}
return s.channels.SendMonoforumMessage(ctx, req)
}
// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。
func (s *Service) ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) {
if s == nil || s.channels == nil || filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 {
return domain.ChannelHistory{}, domain.ErrChannelInvalid
}
if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
}
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
return s.channels.ListMonoforumHistory(ctx, filter)
}
// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(管理员视角私信列表)。访问权限(仅管理员)
// 由 RPC 层校验。
func (s *Service) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) {
if s == nil || s.channels == nil || filter.MonoforumID == 0 {
return domain.MonoforumDialogList{}, domain.ErrChannelInvalid
}
if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
return domain.MonoforumDialogList{}, domain.ErrMessageIDInvalid
}
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
return s.channels.ListMonoforumDialogs(ctx, filter)
}
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求成员身份)并返回调用者是否为其母频道管理员。
func (s *Service) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
if s == nil || s.channels == nil || viewerUserID == 0 || monoforumID == 0 {
return domain.Channel{}, false, domain.ErrChannelInvalid
}
return s.channels.ResolveMonoforumSend(ctx, viewerUserID, monoforumID)
}
// GetUnreadMentions returns a bounded unread mention page for a channel/supergroup.
@ -1321,7 +1823,73 @@ func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
}
}
return s.channels.GetChannelMessages(ctx, userID, channelID, ids)
history, err := s.channels.GetChannelMessages(ctx, userID, channelID, ids)
if err != nil {
return domain.ChannelHistory{}, err
}
return s.filterBotChannelHistory(ctx, userID, history), nil
}
// ChannelPollFanoutViews 批量为一组 viewer 返回频道 poll 消息的 per-viewer enrich(fan-out 模板化,
// 消除逐 viewer GetMessages 的 N+1)。store 负责成员/AvailableMinID 可见性 + 模板聚合;此处叠加
// bot 历史可见性过滤(复刻 filterBotChannelHistory:无 ChatHistory 的 bot 看不到该消息→置 nil)。
// 返回 map[viewer]:key 存在=已评估(nil=不可见,调用方据此跳过且无需回退);非 nil=该 viewer enrich poll。
func (s *Service) ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (map[int64]*domain.MessagePoll, error) {
if s == nil || s.channels == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 {
return map[int64]*domain.MessagePoll{}, nil
}
views, err := s.channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, now)
if err != nil {
return nil, err
}
if !views.Found {
return map[int64]*domain.MessagePoll{}, nil
}
if s.bots != nil {
for viewer, poll := range views.Polls {
if poll == nil {
continue
}
if !s.botViewerCanSeeChannelMessage(ctx, viewer, views.Message) {
views.Polls[viewer] = nil
}
}
}
return views.Polls, nil
}
// botViewerCanSeeChannelMessage 复刻 filterBotChannelHistory 的单消息判定:非 bot 或带 ChatHistory
// 的 bot 一律可见;无 ChatHistory 的 bot 按 botCanSeeChannelMessage 判定该条消息是否可见。
func (s *Service) botViewerCanSeeChannelMessage(ctx context.Context, viewer int64, msg domain.ChannelMessage) bool {
if s.bots == nil || viewer == 0 {
return true
}
profile, found, err := s.bots.BotInfo(ctx, viewer)
if err != nil || !found || profile.ChatHistory {
return true
}
visible, err := s.botCanSeeChannelMessage(ctx, viewer, msg, nil)
return err == nil && visible
}
// ListStoryMessageForwards returns public channel/supergroup messages that
// shared a source story as messageMediaStory.
func (s *Service) ListStoryMessageForwards(ctx context.Context, userID int64, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
if s == nil || s.channels == nil || userID == 0 {
return domain.StoryMessageForwardList{}, domain.ErrChannelInvalid
}
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID || req.Owner.ID == 0 {
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
}
if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel {
return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryMessageForwardList{}, err
}
req.ViewerUserID = userID
req.Limit = capLimit(req.Limit, domain.MaxStoryInteractionListLimit)
return s.channels.ListStoryMessageForwards(ctx, req)
}
// GetDiscussionMessage returns the root message used to open a discussion thread.
@ -1349,6 +1917,28 @@ func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.Read
return s.channels.ReadChannelHistory(ctx, req)
}
// ReadTopicHistory advances current user's per-topic read watermark inside a forum.
func (s *Service) ReadTopicHistory(ctx context.Context, userID int64, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID {
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
}
return s.channels.ReadChannelTopicHistory(ctx, req)
}
// GeneralForumTopic 现算 forum General 话题(id=1)对 viewer 的状态。
func (s *Service) GeneralForumTopic(ctx context.Context, userID, channelID int64) (domain.ChannelForumTopic, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
}
return s.channels.GeneralForumTopic(ctx, userID, channelID)
}
// GetMessageReadParticipants returns a bounded read receipt list for a small megagroup message.
func (s *Service) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
@ -1372,7 +1962,7 @@ func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChan
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
limit = domain.MaxSynchronousChannelDialogFanout
}
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
return s.cachedActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
// DirtyActiveChannelsForUser pages active joined channels with channel events after sinceDate.
@ -1433,7 +2023,19 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch
return domain.ChannelDifference{}, domain.ErrChannelInvalid
}
req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit)
return s.channels.ListChannelDifference(ctx, req)
diff, err := s.channels.ListChannelDifference(ctx, req)
if err != nil {
return domain.ChannelDifference{}, err
}
return s.filterBotChannelDifference(ctx, userID, diff), nil
}
// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。
func (s *Service) ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error {
if s == nil || s.channels == nil || channelID == 0 || messageID <= 0 {
return domain.ErrChannelInvalid
}
return s.channels.ClearDanglingPinnedMessage(ctx, channelID, messageID)
}
func capLimit(limit, max int) int {
@ -1449,28 +2051,6 @@ func capLimit(limit, max int) int {
return limit
}
func uniqueNonZeroLimit(ids []int64, limit int) []int64 {
if limit <= 0 {
return nil
}
out := make([]int64, 0, minInt(len(ids), limit))
seen := make(map[int64]struct{}, minInt(len(ids), limit))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
if len(out) >= limit {
break
}
}
return out
}
func uniqueNonZero(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
@ -1487,11 +2067,26 @@ func uniqueNonZero(ids []int64) []int64 {
return out
}
func minInt(a, b int) int {
if a < b {
return a
func uniqueUserIDs(ids ...int64) []int64 {
return uniqueNonZero(ids)
}
func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelMember) []int64 {
ids := make([]int64, 0, len(members)+1)
if primary != 0 {
ids = append(ids, primary)
}
return b
for _, member := range members {
ids = append(ids, member.UserID)
}
return uniqueNonZero(ids)
}
func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
if s == nil || s.activeIDsCache == nil {
return
}
s.activeIDsCache.invalidateUsers(userIDs...)
}
func normalizeChannelUsername(username string) string {

View file

@ -0,0 +1,38 @@
package channels
import (
"context"
"telesrv/internal/domain"
)
// SetActiveCall 写入/清除频道行上的活跃群通话关联(groupcalls 模块专用)。
func (s *Service) SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetActiveCall(ctx, channelID, callID, callAccessHash, notEmpty)
}
// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道 pts)。
func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
if s == nil || s.channels == nil {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
return domain.SendChannelMessageResult{}, err
}
return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action)
}
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息,
// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。
func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
if s == nil || s.channels == nil {
return domain.ErrChannelInvalid
}
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
return err
}
return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
package contacts
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
contactAccountReadModel = "contact_account"
defaultContactListReadModelTTL = 24 * time.Hour
contactListReadModelMaxUsers = 4096
)
// contactListReadModelCache 是 contact list read-model 的 per-viewer 缓存,由统一缓存原语
// readmodelcache.Cache 承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone 内建)。
type contactListReadModelCache struct {
cache *readmodelcache.Cache[int64, domain.ContactList]
}
func newContactListReadModelCache(ttl time.Duration) *contactListReadModelCache {
if ttl <= 0 {
ttl = defaultContactListReadModelTTL
}
return &contactListReadModelCache{
cache: readmodelcache.New[int64, domain.ContactList](readmodelcache.Config[int64, domain.ContactList]{
MaxEntries: contactListReadModelMaxUsers,
TTL: ttl,
Clone: cloneContactList,
}),
}
}
// getOrLoad 命中即返回 clone,否则经 singleflight load。版本闸门:hasHash 且 currentHash!=0
// 时仅复用 storedHash==currentHash 的快照,否则重载(对齐 contact_account 版本脊)。
func (c *contactListReadModelCache) getOrLoad(ctx context.Context, userID int64, currentHash int64, hasHash bool, load func() (domain.ContactList, error)) (domain.ContactList, error) {
if c == nil {
return load()
}
effectiveHash := int64(0)
if hasHash {
effectiveHash = currentHash
}
return c.cache.GetOrLoadVersioned(ctx, userID, effectiveHash, load)
}
func (c *contactListReadModelCache) invalidate(ids ...int64) {
if c == nil {
return
}
c.cache.Invalidate(ids...)
}
func (c *contactListReadModelCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (s *Service) contactAccountHash(ctx context.Context, userID int64) (int64, bool, error) {
if s == nil || s.versions == nil || userID == 0 {
return 0, false, nil
}
return s.versions.ReadModelHash(ctx, contactAccountReadModel, userID, domain.PeerTypeUser, userID)
}
func (s *Service) contactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) {
if s == nil {
return domain.ContactList{}, nil
}
if s.cache == nil {
return s.loadContactListReadModel(ctx, userID, currentHash, hasHash)
}
return s.cache.getOrLoad(ctx, userID, currentHash, hasHash, func() (domain.ContactList, error) {
return s.loadContactListReadModel(ctx, userID, currentHash, hasHash)
})
}
func (s *Service) loadContactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) {
list, err := s.contacts.ListByUser(ctx, userID)
if err != nil {
return domain.ContactList{}, err
}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
return domain.ContactList{}, err
}
if hasHash && currentHash != 0 {
list.Hash = currentHash
}
return list, nil
}
func (s *Service) InvalidateViewers(ids ...int64) {
if s == nil || s.cache == nil {
return
}
s.cache.invalidate(ids...)
}
func (s *Service) FlushReadModelCache() {
if s == nil || s.cache == nil {
return
}
s.cache.flush()
}
func cloneContactList(in domain.ContactList) domain.ContactList {
in.Contacts = cloneContacts(in.Contacts)
return in
}
func cloneContacts(in []domain.Contact) []domain.Contact {
out := make([]domain.Contact, len(in))
for i := range in {
out[i] = cloneContact(in[i])
}
return out
}
func cloneContact(in domain.Contact) domain.Contact {
in.User = cloneUser(in.User)
if in.NoteEntities != nil {
in.NoteEntities = append([]domain.MessageEntity(nil), in.NoteEntities...)
}
return in
}
func cloneUser(in domain.User) domain.User {
if in.PhotoStripped != nil {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
}
return in
}

View file

@ -18,6 +18,7 @@ var (
)
const maxSearchLimit = 50
const maxCloseFriendsCount = 5000
type phonePrivacyService interface {
userprojection.PrivacyEvaluator
@ -31,6 +32,8 @@ type Service struct {
photos userprojection.ProfilePhotoProvider
privacy phonePrivacyService
projector *userprojection.Projector
versions store.ReadModelVersionStore
cache *contactListReadModelCache
}
// Option adjusts optional contacts service dependencies.
@ -46,9 +49,14 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
return func(s *Service) { s.privacy = p }
}
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 contacts 服务。
func NewService(contacts store.ContactStore, users ...store.UserStore) *Service {
s := &Service{contacts: contacts}
s := &Service{contacts: contacts, cache: newContactListReadModelCache(defaultContactListReadModelTTL)}
if len(users) > 0 {
s.users = users[0]
}
@ -84,16 +92,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
if s == nil || s.contacts == nil || userID == 0 {
return domain.ContactList{}, false, nil
}
list, err := s.contacts.ListByUser(ctx, userID)
currentHash, hasHash, err := s.contactAccountHash(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 && hasHash && hash == currentHash {
return domain.ContactList{Hash: currentHash}, true, nil
}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
list, err := s.contactListReadModel(ctx, userID, currentHash, hasHash)
if err != nil {
return domain.ContactList{}, false, err
}
if hash != 0 && hash == list.Hash {
@ -102,40 +109,6 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
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
@ -143,6 +116,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
if input.FirstName == "" && input.LastName == "" {
return domain.Contact{}, ErrContactNameEmpty
}
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
input.Phone = digitsOnly(input.Phone)
if s.users != nil {
target, found, err := s.users.ByID(ctx, input.ContactUserID)
if err != nil {
@ -159,6 +135,7 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
if err != nil {
return domain.Contact{}, err
}
s.InvalidateViewers(userID, input.ContactUserID)
if input.AddPhonePrivacyException && s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil {
return domain.Contact{}, err
@ -205,6 +182,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
if err != nil {
return domain.Contact{}, err
}
s.InvalidateViewers(userID, contactUserID)
if s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
return domain.Contact{}, err
@ -288,6 +266,12 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
if err != nil {
return domain.ImportContactsResult{}, err
}
changedIDs := make([]int64, 0, len(upserts)+1)
changedIDs = append(changedIDs, userID)
for _, input := range upserts {
changedIDs = append(changedIDs, input.ContactUserID)
}
s.InvalidateViewers(changedIDs...)
if s.privacy != nil {
for _, input := range upserts {
if !input.AddPhonePrivacyException || input.ContactUserID == 0 {
@ -331,7 +315,45 @@ func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserI
if s == nil || s.contacts == nil || userID == 0 {
return 0, nil
}
return s.contacts.Delete(ctx, userID, contactUserIDs)
count, err := s.contacts.Delete(ctx, userID, contactUserIDs)
if err == nil {
ids := make([]int64, 0, len(contactUserIDs)+1)
ids = append(ids, userID)
ids = append(ids, contactUserIDs...)
s.InvalidateViewers(ids...)
}
return count, err
}
func (s *Service) EditCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) {
if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) > maxCloseFriendsCount {
return domain.CloseFriendsEditResult{}, ErrContactIDInvalid
}
ids := normalizeCloseFriendIDs(userID, contactUserIDs)
if s.users != nil && len(ids) > 0 {
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return domain.CloseFriendsEditResult{}, err
}
exists := make(map[int64]struct{}, len(users))
for _, user := range users {
if user.ID != 0 && !user.Bot {
exists[user.ID] = struct{}{}
}
}
filtered := ids[:0]
for _, id := range ids {
if _, ok := exists[id]; ok {
filtered = append(filtered, id)
}
}
ids = filtered
}
result, err := s.contacts.SetCloseFriends(ctx, userID, ids)
if err == nil {
s.InvalidateViewers(userID)
}
return result, err
}
func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) {
@ -345,6 +367,7 @@ func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID i
if !found {
return domain.Contact{}, ErrContactIDInvalid
}
s.InvalidateViewers(userID)
return contact, nil
}
@ -359,6 +382,7 @@ func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID in
if !found {
return domain.Contact{}, ErrContactReqMissing
}
s.InvalidateViewers(userID)
return s.projectContact(ctx, userID, contact)
}
@ -373,6 +397,7 @@ func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID
if !found {
return domain.Contact{}, ErrContactReqMissing
}
s.InvalidateViewers(userID)
return s.projectContact(ctx, userID, contact)
}
@ -422,7 +447,11 @@ func (s *Service) BlockContact(ctx context.Context, userID, peerUserID int64, da
return false, ErrContactIDInvalid
}
}
return s.contacts.Block(ctx, userID, peerUserID, date)
changed, err := s.contacts.Block(ctx, userID, peerUserID, date)
if err == nil {
s.InvalidateViewers(userID, peerUserID)
}
return changed, err
}
// UnblockContact removes peer from the current user's blocklist.
@ -430,7 +459,11 @@ func (s *Service) UnblockContact(ctx context.Context, userID, peerUserID int64)
if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID {
return false, ErrContactIDInvalid
}
return s.contacts.Unblock(ctx, userID, peerUserID)
changed, err := s.contacts.Unblock(ctx, userID, peerUserID)
if err == nil {
s.InvalidateViewers(userID, peerUserID)
}
return changed, err
}
// IsBlocked reports whether owner has blocked peer.
@ -509,10 +542,10 @@ func (s *Service) projectSearchResult(ctx context.Context, userID int64, res dom
return res, nil
}
func normalizePhone(phone string) string {
if !utf8.ValidString(phone) {
return ""
}
// digitsOnly 只保留数字字符。保存进 contacts.contact_phone 的号码必须与 users.phone
// 一样是不带 "+" 的纯数字:下发时 contact_phone 优先充当 TL user.phone,而客户端展示
// user.phone 时会自行补 "+",任何非数字前缀都会变成 "++<号码>" 这类坏显示。
func digitsOnly(phone string) string {
var b strings.Builder
b.Grow(len(phone))
for _, r := range phone {
@ -520,8 +553,31 @@ func normalizePhone(phone string) string {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}
func normalizePhone(phone string) string {
if !utf8.ValidString(phone) {
return ""
}
if digits := digitsOnly(phone); digits != "" {
return digits
}
return phone
}
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 || id == userID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -3,12 +3,130 @@ package contacts
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type serviceCountingContactStore struct {
store.ContactStore
listCalls int
}
func (s *serviceCountingContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
s.listCalls++
return s.ContactStore.ListByUser(ctx, userID)
}
type serviceCountingUserStore struct {
store.UserStore
byIDsCalls int
}
func (s *serviceCountingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
s.byIDsCalls++
return s.UserStore.ByIDs(ctx, ids)
}
type fakeReadModelVersions struct {
hash int64
found bool
}
func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, _ string, _ int64, _ domain.PeerType, _ int64) (int64, bool, error) {
return f.hash, f.found, nil
}
func (f *fakeReadModelVersions) ReadModelHashes(ctx context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
hash, found, err := f.ReadModelHash(ctx, key.Model, key.OwnerUserID, key.PeerType, key.PeerID)
if err != nil {
return nil, err
}
if found {
out[key] = hash
}
}
return out, nil
}
func TestGetContactsReturnsNotModifiedFromReadModelHashWithoutLoadingList(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
counting := &serviceCountingContactStore{ContactStore: base}
versions := &fakeReadModelVersions{hash: 99101, found: true}
svc := NewService(counting).Configure(WithReadModelVersions(versions))
list, notModified, err := svc.GetContacts(ctx, 1, versions.hash)
if err != nil {
t.Fatalf("GetContacts: %v", err)
}
if !notModified {
t.Fatalf("notModified = false, want true")
}
if list.Hash != versions.hash {
t.Fatalf("notModified list hash = %d, want %d", list.Hash, versions.hash)
}
if counting.listCalls != 0 {
t.Fatalf("ListByUser calls = %d, want 0 on hash hit", counting.listCalls)
}
}
func TestGetContactsCachesProjectedReadModelAndRejectsStaleHash(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
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: "101", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: target.ID, FirstName: "Saved"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
counting := &serviceCountingContactStore{ContactStore: base}
countingUsers := &serviceCountingUserStore{UserStore: users}
versions := &fakeReadModelVersions{hash: 12345, found: true}
svc := NewService(counting, countingUsers).Configure(WithReadModelVersions(versions))
first, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("first GetContacts notModified=%v err=%v", notModified, err)
}
if first.Hash != versions.hash || len(first.Contacts) != 1 {
t.Fatalf("first result hash=%d contacts=%d, want hash %d and one contact", first.Hash, len(first.Contacts), versions.hash)
}
second, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("second GetContacts notModified=%v err=%v", notModified, err)
}
if second.Hash != versions.hash || counting.listCalls != 1 {
t.Fatalf("second result hash=%d listCalls=%d, want cached hash %d and one load", second.Hash, counting.listCalls, versions.hash)
}
versions.hash = 67890
third, notModified, err := svc.GetContacts(ctx, owner.ID, 12345)
if err != nil || notModified {
t.Fatalf("third GetContacts notModified=%v err=%v", notModified, err)
}
if third.Hash != versions.hash {
t.Fatalf("third hash = %d, want new read-model hash %d", third.Hash, versions.hash)
}
if counting.listCalls != 2 {
t.Fatalf("ListByUser calls after hash change = %d, want 2", counting.listCalls)
}
if countingUsers.byIDsCalls != 0 {
t.Fatalf("Users.ByIDs calls = %d, want 0; presence must stay out of contact read model", countingUsers.byIDsCalls)
}
}
func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -89,6 +207,73 @@ func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
}
}
func TestEditCloseFriendsReplacesOwnerContactFlags(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)
}
bob, err := users.Create(ctx, domain.User{Phone: "101", FirstName: "Bob"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
carol, err := users.Create(ctx, domain.User{Phone: "102", FirstName: "Carol"})
if err != nil {
t.Fatalf("create carol: %v", err)
}
bot, err := users.Create(ctx, domain.User{Phone: "103", FirstName: "Bot", Bot: true, BotInfoVersion: 1})
if err != nil {
t.Fatalf("create bot: %v", err)
}
for _, user := range []domain.User{bob, carol, bot} {
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: user.ID, FirstName: user.FirstName}); err != nil {
t.Fatalf("upsert contact %d: %v", user.ID, err)
}
}
svc := NewService(contactsStore, users)
result, err := svc.EditCloseFriends(ctx, owner.ID, []int64{bob.ID, bob.ID, 0, owner.ID, bot.ID, 999999})
if err != nil {
t.Fatalf("EditCloseFriends first: %v", err)
}
if got, want := result.AddedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("first added = %v, want %v", got, want)
}
if len(result.RemovedUserIDs) != 0 {
t.Fatalf("first removed = %v, want empty", result.RemovedUserIDs)
}
list, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("GetContacts after first edit notModified=%v err=%v", notModified, err)
}
if !contactByID(t, list, bob.ID).CloseFriend || !contactByID(t, list, bob.ID).User.CloseFriend {
t.Fatalf("bob close friend projection = %+v, want true", contactByID(t, list, bob.ID))
}
if contactByID(t, list, carol.ID).CloseFriend || contactByID(t, list, bot.ID).CloseFriend {
t.Fatalf("carol/bot close friend flags = %+v / %+v, want false", contactByID(t, list, carol.ID), contactByID(t, list, bot.ID))
}
result, err = svc.EditCloseFriends(ctx, owner.ID, []int64{carol.ID})
if err != nil {
t.Fatalf("EditCloseFriends replace: %v", err)
}
if got, want := result.AddedUserIDs, []int64{carol.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("replace added = %v, want %v", got, want)
}
if got, want := result.RemovedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("replace removed = %v, want %v", got, want)
}
replaced, _, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("GetContacts after replace: %v", err)
}
if contactByID(t, replaced, bob.ID).CloseFriend || !contactByID(t, replaced, carol.ID).CloseFriend {
t.Fatalf("replace flags bob=%+v carol=%+v, want bob false carol true", contactByID(t, replaced, bob.ID), contactByID(t, replaced, carol.ID))
}
}
func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -157,6 +342,55 @@ func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
}
}
func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
alice, err := users.Create(ctx, domain.User{Phone: "15550060301", FirstName: "Alice", LastName: "A"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "15550060302", FirstName: "Bob", LastName: "B"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
svc := NewService(contactsStore, users)
contact, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: "+1 555-006-0302",
FirstName: "Bob B",
})
if err != nil {
t.Fatalf("AddContact: %v", err)
}
if contact.Phone != "15550060302" {
t.Fatalf("contact phone = %q, want digits-only 15550060302", contact.Phone)
}
if contact.User.Phone != "15550060302" {
t.Fatalf("projected user phone = %q, want digits-only 15550060302", contact.User.Phone)
}
stored, found, err := contactsStore.Get(ctx, alice.ID, bob.ID)
if err != nil || !found {
t.Fatalf("stored contact found=%v err=%v", found, err)
}
if stored.Phone != "15550060302" {
t.Fatalf("stored contact phone = %q, want digits-only 15550060302", stored.Phone)
}
emptied, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: "+",
FirstName: "Bob B",
})
if err != nil {
t.Fatalf("AddContact digitless phone: %v", err)
}
if emptied.Phone != bob.Phone {
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
}
}
func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -176,6 +410,17 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
}
}
func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact {
t.Helper()
for _, contact := range list.Contacts {
if contact.User.ID == id {
return contact
}
}
t.Fatalf("contact %d not found in %+v", id, list.Contacts)
return domain.Contact{}
}
type contactProfilePhotos map[int64]domain.ProfilePhotoRef
func (p contactProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {

View file

@ -0,0 +1,132 @@
package dialogs
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
defaultDialogListHashCacheTTL = 24 * time.Hour
dialogListHashCacheMaxEntries = 4096
)
type dialogListHashCacheKey struct {
userID int64
pinnedOnly bool
excludePinned bool
hasFolderID bool
folderID int
limit int
}
type dialogListHashValue struct {
hash int64
count int
}
// dialogListHashCache 由统一缓存原语承载,走「外部构建再写回」(GetDialogs 在加载前 cacheEpoch
// 快照 epoch → rememberDialogListHash 经 putIfEpoch 写回)。原语的 epoch 仅在 Invalidate/Flush
// (写驱动失效)自增,被动 TTL 过期纯 per-key 不动 epoch——正是本缓存所需(避免误返 NotModified)。
type dialogListHashCache struct {
cache *readmodelcache.Cache[dialogListHashCacheKey, dialogListHashValue]
}
func newDialogListHashCache(ttl time.Duration) *dialogListHashCache {
if ttl <= 0 {
ttl = defaultDialogListHashCacheTTL
}
return &dialogListHashCache{
cache: readmodelcache.New[dialogListHashCacheKey, dialogListHashValue](readmodelcache.Config[dialogListHashCacheKey, dialogListHashValue]{
MaxEntries: dialogListHashCacheMaxEntries,
TTL: ttl,
}),
}
}
func (s *Service) GetDialogsHash(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogHashCheck, error) {
if s == nil || s.listHashCache == nil || filter.Hash == 0 {
return domain.DialogHashCheck{}, nil
}
key, ok := dialogListHashKey(userID, filter)
if !ok {
return domain.DialogHashCheck{}, nil
}
snap, ok := s.listHashCache.lookup(key)
if !ok {
return domain.DialogHashCheck{}, nil
}
return domain.DialogHashCheck{
Known: true,
Matched: snap.hash == filter.Hash,
Hash: snap.hash,
Count: snap.count,
}, nil
}
func (s *Service) rememberDialogListHash(userID int64, filter domain.DialogFilter, list domain.DialogList, loadEpoch uint64) {
if s == nil || s.listHashCache == nil || list.Hash == 0 {
return
}
key, ok := dialogListHashKey(userID, filter)
if !ok {
return
}
s.listHashCache.putIfEpoch(key, list.Hash, list.Count, loadEpoch)
}
func dialogListHashKey(userID int64, filter domain.DialogFilter) (dialogListHashCacheKey, bool) {
if userID == 0 || filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer {
return dialogListHashCacheKey{}, false
}
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
return dialogListHashCacheKey{
userID: userID,
pinnedOnly: filter.PinnedOnly,
excludePinned: filter.ExcludePinned,
hasFolderID: filter.HasFolderID,
folderID: filter.FolderID,
limit: limit,
}, true
}
func (c *dialogListHashCache) lookup(key dialogListHashCacheKey) (dialogListHashValue, bool) {
if c == nil {
return dialogListHashValue{}, false
}
return c.cache.Peek(key)
}
// putIfEpoch 仅在 epoch 未变(加载期间没有写驱动失效)时写入,堵住 stale write-back race。
func (c *dialogListHashCache) putIfEpoch(key dialogListHashCacheKey, hash int64, count int, loadEpoch uint64) {
if c == nil || key.userID == 0 || hash == 0 {
return
}
c.cache.StoreIfEpoch(key, dialogListHashValue{hash: hash, count: count}, loadEpoch)
}
func (c *dialogListHashCache) cacheEpoch() uint64 {
if c == nil {
return 0
}
return c.cache.LoadEpoch()
}
func (c *dialogListHashCache) invalidateOwner(userID int64) {
if c == nil || userID == 0 {
return
}
c.cache.InvalidateWhere(func(key dialogListHashCacheKey) bool { return key.userID == userID })
}
func (c *dialogListHashCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}

View file

@ -0,0 +1,506 @@
package dialogs
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
dialogLightReadModel = readmodel.ModelDialogLight
channelBaseReadModel = readmodel.ModelChannelBase
channelMemberReadModel = readmodel.ModelChannelMember
defaultDialogPeerReadModelTTL = 24 * time.Hour
dialogPeerReadModelMaxEntries = 8192
)
type dialogPeerCacheKey struct {
userID int64
peer domain.Peer
}
// dialogPeerReadModelCache 由统一缓存原语承载(epoch 守卫 / LRU / clone)。它走 per-peer
// 外部构建:Service 按 peer 查缓存、把 miss 合批打一次后端、再 per-peer 写回。版本闸门用
// 值自带的 DialogList.Hash 比对(原语存 hash=0,版本由值携带)。返回的是整批原始 list(非
// per-peer 切片重组),故不用 GetOrLoadBatch——它返回 per-key 值会丢非 peer 归属的全局元素。
type dialogPeerReadModelCache struct {
cache *readmodelcache.Cache[dialogPeerCacheKey, domain.DialogList]
}
func newDialogPeerReadModelCache(ttl time.Duration) *dialogPeerReadModelCache {
if ttl <= 0 {
ttl = defaultDialogPeerReadModelTTL
}
return &dialogPeerReadModelCache{
cache: readmodelcache.New[dialogPeerCacheKey, domain.DialogList](readmodelcache.Config[dialogPeerCacheKey, domain.DialogList]{
MaxEntries: dialogPeerReadModelMaxEntries,
TTL: ttl,
Clone: cloneDialogList,
}),
}
}
func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil {
return domain.DialogList{}, nil
}
unique := uniqueUserPeers(peers)
if len(unique) == 0 {
return domain.DialogList{}, nil
}
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.userDialogHashes, s.loadUserPeerDialogs)
}
func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) {
if s == nil {
return domain.DialogList{}, nil
}
unique := uniqueChannelPeers(channelIDs)
if len(unique) == 0 {
return domain.DialogList{}, nil
}
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.channelDialogHashes, s.loadChannelPeerDialogsByPeers)
}
func (s *Service) cachedPeerDialogsReadModel(
ctx context.Context,
userID int64,
peers []domain.Peer,
hashesFor func(context.Context, int64, []domain.Peer) (map[domain.Peer]int64, error),
load func(context.Context, int64, []domain.Peer) (domain.DialogList, error),
) (domain.DialogList, error) {
if s.peerCache == nil || s.versions == nil {
return load(ctx, userID, peers)
}
hashes, err := hashesFor(ctx, userID, peers)
if err != nil {
return domain.DialogList{}, err
}
loadEpoch := s.peerCache.cacheEpoch()
var out domain.DialogList
misses := make([]domain.Peer, 0, len(peers))
for _, peer := range peers {
hash := hashes[peer]
if hash != 0 {
if cached, ok := s.peerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
out = mergeDialogLists(out, cached)
continue
}
}
misses = append(misses, peer)
}
if len(misses) == 0 {
out.Count = len(out.Dialogs)
return out, nil
}
list, err := load(ctx, userID, misses)
if err != nil {
return domain.DialogList{}, err
}
for _, peer := range misses {
hash := hashes[peer]
if hash == 0 {
continue
}
peerList := dialogListForPeer(list, peer)
peerList.Hash = hash
s.peerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
}
if len(out.Dialogs) > 0 || len(out.Messages) > 0 || len(out.ChannelMessages) > 0 || len(out.Users) > 0 || len(out.Channels) > 0 {
return mergeDialogLists(out, list), nil
}
return list, nil
}
func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || s.dialogs == nil || len(peers) == 0 {
return domain.DialogList{}, nil
}
list, err := s.dialogs.ListByPeers(ctx, userID, peers)
if err != nil {
return domain.DialogList{}, err
}
if err := s.attachDrafts(ctx, userID, &list); err != nil {
return domain.DialogList{}, err
}
if err := s.projectDialogUsers(ctx, userID, &list); err != nil {
return domain.DialogList{}, err
}
return list, nil
}
func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || s.channels == nil || len(peers) == 0 {
return domain.DialogList{}, nil
}
channelIDs := channelPeerIDs(peers)
if len(channelIDs) == 0 {
return domain.DialogList{}, nil
}
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
if err != nil {
return domain.DialogList{}, err
}
out := mergeChannelDialogs(domain.DialogList{}, 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
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
return out, nil
}
func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
keys := make([]store.ReadModelKey, 0, len(peers))
for _, peer := range peers {
keys = append(keys, store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID})
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return nil, err
}
out := make(map[domain.Peer]int64, len(peers))
for _, peer := range peers {
out[peer] = rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
}
return out, nil
}
func (s *Service) channelDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
keys := make([]store.ReadModelKey, 0, len(peers)*3)
for _, peer := range peers {
keys = append(keys,
store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID},
store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
)
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return nil, err
}
out := make(map[domain.Peer]int64, len(peers))
for _, peer := range peers {
base := rows[store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}]
if base == 0 {
continue
}
member := rows[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
dialog := rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
out[peer] = readmodel.MixHashes(base, member, dialog)
}
return out, nil
}
// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。
func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) {
if c == nil {
return domain.DialogList{}, false
}
list, ok := c.cache.Peek(key)
if !ok || (currentHash != 0 && list.Hash != currentHash) {
return domain.DialogList{}, false
}
return list, true
}
func (c *dialogPeerReadModelCache) putIfEpoch(key dialogPeerCacheKey, list domain.DialogList, hash int64, expectedEpoch uint64) {
if c == nil || key.userID == 0 || key.peer.Type == "" || key.peer.ID == 0 || hash == 0 {
return
}
list.Hash = hash
c.cache.StoreIfEpoch(key, list, expectedEpoch)
}
func (c *dialogPeerReadModelCache) invalidate(key dialogPeerCacheKey) {
if c == nil {
return
}
c.cache.Invalidate(key)
}
func (c *dialogPeerReadModelCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (c *dialogPeerReadModelCache) cacheEpoch() uint64 {
if c == nil {
return 0
}
return c.cache.LoadEpoch()
}
func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) {
if s == nil || userID == 0 {
return
}
s.invalidateDialogListHashes(userID)
if s.peerCache == nil || peer.Type == "" || peer.ID == 0 {
return
}
s.peerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
}
func (s *Service) FlushReadModelCache() {
if s == nil {
return
}
if s.peerCache != nil {
s.peerCache.flush()
}
if s.listHashCache != nil {
s.listHashCache.flush()
}
}
func (s *Service) invalidateDialogListHashes(userID int64) {
if s == nil || s.listHashCache == nil || userID == 0 {
return
}
s.listHashCache.invalidateOwner(userID)
}
func uniqueUserPeers(peers []domain.Peer) []domain.Peer {
return uniquePeersOfType(peers, domain.PeerTypeUser)
}
func uniqueChannelPeers(channelIDs []int64) []domain.Peer {
out := make([]domain.Peer, 0, len(channelIDs))
seen := make(map[int64]struct{}, len(channelIDs))
for _, id := range channelIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: id})
}
return out
}
func uniquePeersOfType(peers []domain.Peer, peerType domain.PeerType) []domain.Peer {
out := make([]domain.Peer, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type != peerType || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
out = append(out, peer)
}
return out
}
func channelPeerIDs(peers []domain.Peer) []int64 {
out := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == domain.PeerTypeChannel && peer.ID != 0 {
out = append(out, peer.ID)
}
}
return out
}
func dialogListForPeer(list domain.DialogList, peer domain.Peer) domain.DialogList {
out := domain.DialogList{Hash: list.Hash}
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
out.Dialogs = append(out.Dialogs, cloneDialog(dialog))
}
}
for _, msg := range list.Messages {
if msg.Peer == peer {
out.Messages = append(out.Messages, cloneMessageForDialogCache(msg))
}
}
for _, msg := range list.ChannelMessages {
if msg.ChannelID == peer.ID && peer.Type == domain.PeerTypeChannel {
out.ChannelMessages = append(out.ChannelMessages, cloneChannelMessageForDialogCache(msg))
}
}
switch peer.Type {
case domain.PeerTypeUser:
for _, user := range list.Users {
if user.ID == peer.ID {
out.Users = append(out.Users, cloneDialogUser(user))
}
}
case domain.PeerTypeChannel:
var linkedID int64
for _, channel := range list.Channels {
if channel.ID == peer.ID {
out.Channels = append(out.Channels, cloneDialogChannel(channel))
// monoforum 与母广播频道互为 linked_monoforum_id。per-peer 缓存必须把同批下发的关联频道
// 一并保留,否则缓存命中时 getPeerDialogs 只回该 peer 自身、丢掉关联频道,客户端无法
// resolve linked_monoforum_id(GetChannelDialogs 的同批下发在缓存层被抹掉)。
if channel.LinkedMonoforumID != 0 && (channel.Monoforum || channel.BroadcastMessagesAllowed) {
linkedID = channel.LinkedMonoforumID
}
}
}
if linkedID != 0 {
for _, channel := range list.Channels {
if channel.ID == linkedID {
out.Channels = append(out.Channels, cloneDialogChannel(channel))
}
}
}
}
out.Count = len(out.Dialogs)
return out
}
func cloneDialogList(in domain.DialogList) domain.DialogList {
in.Dialogs = cloneDialogSlice(in.Dialogs)
in.Messages = cloneDialogMessages(in.Messages)
in.ChannelMessages = cloneDialogChannelMessages(in.ChannelMessages)
in.Users = cloneDialogUsers(in.Users)
in.Channels = cloneDialogChannels(in.Channels)
return in
}
func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
out := make([]domain.Dialog, len(in))
for i := range in {
out[i] = cloneDialog(in[i])
}
return out
}
func cloneDialog(in domain.Dialog) domain.Dialog {
if in.Draft != nil {
draft := cloneDraft(*in.Draft)
in.Draft = &draft
}
return in
}
func cloneDialogMessages(in []domain.Message) []domain.Message {
out := make([]domain.Message, len(in))
for i := range in {
out[i] = cloneMessageForDialogCache(in[i])
}
return out
}
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
msg.ReplyTo = &reply
}
if msg.Forward != nil {
forward := *msg.Forward
msg.Forward = &forward
}
return msg
}
func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMessage {
out := make([]domain.ChannelMessage, len(in))
for i := range in {
out[i] = cloneChannelMessageForDialogCache(in[i])
}
return out
}
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
msg.ReplyTo = &reply
}
if msg.Forward != nil {
forward := *msg.Forward
msg.Forward = &forward
}
if msg.SendAs != nil {
sendAs := *msg.SendAs
msg.SendAs = &sendAs
}
if msg.Reactions != nil {
reactions := *msg.Reactions
reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...)
reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...)
msg.Reactions = &reactions
}
if msg.ReplyMarkup != nil {
msg.ReplyMarkup = cloneReplyMarkupForDialogCache(msg.ReplyMarkup)
}
if msg.Action != nil {
action := *msg.Action
action.UserIDs = append([]int64(nil), msg.Action.UserIDs...)
action.Completed = append([]int(nil), msg.Action.Completed...)
action.Incompleted = append([]int(nil), msg.Action.Incompleted...)
action.TodoItems = append([]domain.MessageTodoItem(nil), msg.Action.TodoItems...)
msg.Action = &action
}
return msg
}
func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
if in == nil {
return nil
}
out := &domain.MessageReplyMarkup{}
if len(in.Inline) > 0 {
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
for i, row := range in.Inline {
out.Inline[i] = make([]domain.MarkupButton, len(row))
for j, button := range row {
out.Inline[i][j] = button
out.Inline[i][j].Data = append([]byte(nil), button.Data...)
}
}
}
return out
}
func cloneDialogUsers(in []domain.User) []domain.User {
out := make([]domain.User, len(in))
for i := range in {
out[i] = cloneDialogUser(in[i])
}
return out
}
func cloneDialogUser(in domain.User) domain.User {
if in.PhotoStripped != nil {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
}
return in
}
func cloneDialogChannels(in []domain.Channel) []domain.Channel {
out := make([]domain.Channel, len(in))
for i := range in {
out[i] = cloneDialogChannel(in[i])
}
return out
}
func cloneDialogChannel(in domain.Channel) domain.Channel {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...)
in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...)
return in
}

View file

@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"hash/fnv"
"reflect"
"sort"
"unicode/utf8"
@ -13,14 +14,21 @@ import (
"telesrv/internal/store"
)
// PremiumChecker 报告用户当前是否有效会员(pin 上限双档判断用)。
type PremiumChecker func(ctx context.Context, userID int64) bool
// Service 提供会话列表查询。
type Service struct {
dialogs store.DialogStore
channels store.ChannelStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
projector *userprojection.Projector
dialogs store.DialogStore
channels store.ChannelStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
premium PremiumChecker
projector *userprojection.Projector
versions store.ReadModelVersionStore
peerCache *dialogPeerReadModelCache
listHashCache *dialogListHashCache
}
// Option adjusts optional dialogs service dependencies.
@ -31,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
// WithPremiumChecker 启用 pin 上限的 premium 双档(缺省一律按默认档)。
func WithPremiumChecker(p PremiumChecker) Option {
return func(s *Service) { s.premium = p }
}
// WithPhotoProvider enables current profile photo enrichment for dialog users.
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
@ -41,9 +54,18 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
return func(s *Service) { s.privacy = p }
}
// WithReadModelVersions enables durable version-token backed peer dialog caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 dialogs 服务。
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
s := &Service{dialogs: dialogs}
s := &Service{
dialogs: dialogs,
peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
}
if len(channels) > 0 {
s.channels = channels[0]
}
@ -76,6 +98,15 @@ func (s *Service) rebuildProjector() {
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
return s.getDialogs(ctx, userID, filter, false)
}
// getDialogs 是 GetDialogs 的实现。lightweight=true 跳过草稿附加、viewer 投影与
// list-hash 写回,供 attachArchiveSummary 取归档顶部会话用:归档摘要只需 top
// peer/message,草稿无意义;且追加进来的归档 users 会被外层 GetDialogs 的
// projectDialogUsers 统一投影,内层再投影纯属重复(原归档递归走完整 GetDialogs
// 会多跑一次 ListDrafts + 一次投影)。
func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) {
if s == nil || userID == 0 {
return domain.DialogList{}, nil
}
@ -92,6 +123,9 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di
}
filter.Folder = &folder
}
// 在加载任何会话状态前快照 list-hash epoch:若加载/投影期间发生 dialog_light 写失效,
// rememberDialogListHash 会据此拒绝写回 stale hash,避免后续 getDialogs 误返 NotModified。
listHashEpoch := s.listHashCache.cacheEpoch()
var out domain.DialogList
if s.dialogs != nil {
list, err := s.dialogs.ListByUser(ctx, userID, filter)
@ -125,15 +159,100 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di
if out.Count == 0 {
out.Count = len(out.Dialogs)
}
if err := s.attachArchiveSummary(ctx, userID, filter, &out); err != nil {
return domain.DialogList{}, err
}
if lightweight {
// 归档摘要顶部会话:不附草稿、不投影(由外层统一投影)、不写 list-hash。
return out, nil
}
if err := s.attachDrafts(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
s.rememberDialogListHash(userID, filter, out, listHashEpoch)
return out, nil
}
// attachArchiveSummary 在主列表第一页响应上聚合归档摘要:TDesktop 只能靠
// 响应头部的 dialogFolder 条目发现 archive(新登录设备没有任何 update 可
// 重放),缺少它归档会话将彻底不可见。归档/自定义 filter/置顶/翻页请求不附加。
func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter domain.DialogFilter, out *domain.DialogList) error {
if s == nil || out == nil {
return nil
}
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
return nil
}
// exclude_pinned 请求按官方语义排除 folder 条目(archive 行属 pinned 集合)。
// PinnedOnly(getPinnedDialogs)不能跳过:DrKLO 主列表 getDialogs 一律带
// exclude_pinned,archive 行的发现完全依赖 getPinnedDialogs 响应里的
// dialogFolder 条目(fetchFolderInLoadedPinnedDialogs)。
if filter.ExcludePinned {
return nil
}
if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer {
return nil
}
top, err := s.getDialogs(ctx, userID, domain.DialogFilter{
HasFolderID: true,
FolderID: domain.DialogArchiveFolderID,
Limit: 1,
}, true)
if err != nil {
return err
}
if len(top.Dialogs) == 0 {
return nil
}
unreadPeers, unreadMessages := 0, 0
if s.dialogs != nil {
peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
if s.channels != nil {
peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
archivePinned := true
if s.dialogs != nil {
pinned, err := s.dialogs.ArchivePinned(ctx, userID)
if err != nil {
return err
}
archivePinned = pinned
}
// getPinnedDialogs 只返回置顶集合:archive 行被 unpin 后不再属于它。
if filter.PinnedOnly && !archivePinned {
return nil
}
topDialog := top.Dialogs[0]
out.ArchiveSummary = &domain.DialogArchiveSummary{
TopPeer: topDialog.Peer,
TopMessage: topDialog.TopMessage,
UnreadPeersCount: unreadPeers,
UnreadMessagesCount: unreadMessages,
Pinned: archivePinned,
}
// dialogFolder.peer 指向的会话对象必须随响应下发:TDesktop
// Folder::applyDialog 会立即解引用该 peer(owner().history(peerId))。
out.Messages = append(out.Messages, top.Messages...)
out.ChannelMessages = append(out.ChannelMessages, top.ChannelMessages...)
out.Users = append(out.Users, top.Users...)
out.Channels = append(out.Channels, top.Channels...)
return 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 {
@ -154,28 +273,18 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma
}
var out domain.DialogList
if len(userPeers) > 0 && s.dialogs != nil {
list, err := s.dialogs.ListByPeers(ctx, userID, userPeers)
list, err := s.userPeerDialogsReadModel(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)
channelOut, err := s.channelPeerDialogsReadModel(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
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
out = mergeDialogLists(out, channelOut)
}
return out, nil
}
@ -191,6 +300,7 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
}
}
seen := make(map[int64]struct{}, len(channelIDs))
missingIDs := make([]int64, 0, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
continue
@ -203,12 +313,28 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
continue
}
view, err := s.channels.GetChannel(ctx, userID, channelID)
if err != nil {
if isChannelPreviewAccessError(err) {
continue
}
return domain.DialogList{}, err
missingIDs = append(missingIDs, channelID)
}
if len(missingIDs) == 0 {
return out, nil
}
views, err := s.channels.GetChannels(ctx, userID, missingIDs)
if err != nil {
if isChannelPreviewAccessError(err) {
return out, nil
}
return domain.DialogList{}, err
}
viewsByID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
if view.Channel.ID != 0 {
viewsByID[view.Channel.ID] = view
}
}
for _, channelID := range missingIDs {
view, ok := viewsByID[channelID]
if !ok || view.Forbidden {
continue
}
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
ChannelID: channelID,
@ -256,26 +382,63 @@ func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: view.Channel.Pts,
}
}
// SaveDraft stores or clears a cloud draft for one peer/topic.
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
// It returns whether the authoritative draft content changed. A repeated save
// with identical content does not refresh Date, invalidate read models, or
// force a durable update.
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
return false, nil
}
if err := validateDraft(draft); err != nil {
return err
return false, err
}
if draft.Empty() {
_, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
return err
changed, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, draft.Peer)
}
return changed, err
}
return s.dialogs.SaveDraft(ctx, userID, draft)
existing, found, err := s.dialogs.GetDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err != nil {
return false, err
}
if found && sameDialogDraftContent(existing, draft) {
return false, nil
}
if err := s.dialogs.SaveDraft(ctx, userID, draft); err != nil {
return false, err
}
s.InvalidateDialog(userID, draft.Peer)
return true, nil
}
func sameDialogDraftContent(a, b domain.DialogDraft) bool {
a.Date = 0
b.Date = 0
return reflect.DeepEqual(a, b)
}
// GetDraft 读取某会话当前云草稿(draft_message 事件重放时按 peer 重载用)。
func (s *Service) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return domain.DialogDraft{}, false, nil
}
if err := validateDraftKey(peer, topMessageID); err != nil {
return domain.DialogDraft{}, false, err
}
return s.dialogs.GetDraft(ctx, userID, peer, topMessageID)
}
// DeleteDraft clears one cloud draft.
@ -286,7 +449,11 @@ func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Pee
if err := validateDraftKey(peer, topMessageID); err != nil {
return false, err
}
return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
changed, err := s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
@ -302,42 +469,185 @@ func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]d
if s == nil || s.dialogs == nil || userID == 0 {
return nil, nil
}
return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
drafts, err := s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
if err == nil {
for _, draft := range drafts {
s.InvalidateDialog(userID, draft.Peer)
}
}
return drafts, err
}
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
// TogglePinned 置顶/取消置顶一条会话;置顶顺序在会话当前 folder 内分配,
// 返回 (changed, 该会话所在 folder_id) 供 updateDialogPinned.folder_id 使用。
// pin 时按 folder 校验上限(重复 pin 幂等放行),超限返回 ErrPinnedDialogsTooMuch。
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
return false, 0, nil
}
if pinned {
if err := s.checkPinnedLimit(ctx, userID, peer); err != nil {
return false, 0, err
}
}
switch peer.Type {
case domain.PeerTypeChannel:
if s.channels == nil {
return false, nil
return false, 0, nil
}
return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
changed, folderID, err := s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
default:
if s.dialogs == nil {
return false, nil
return false, 0, nil
}
return s.dialogs.SetPinned(ctx, userID, peer, pinned)
changed, folderID, err := s.dialogs.SetPinned(ctx, userID, peer, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
}
}
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
if s == nil || userID == 0 {
func (s *Service) promotePinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return nil
}
list, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: domain.PinnedDialogsLimit(folderID, true),
})
if err != nil {
return err
}
order := make([]domain.Peer, 0, len(list.Dialogs))
order = append(order, peer)
seen := map[domain.Peer]struct{}{peer: {}}
found := false
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
found = true
continue
}
if !dialog.Pinned {
continue
}
if _, ok := seen[dialog.Peer]; ok {
continue
}
seen[dialog.Peer] = struct{}{}
order = append(order, dialog.Peer)
}
if !found {
return nil
}
_, err = s.ReorderPinned(ctx, userID, folderID, order, false)
return err
}
func (s *Service) checkPinnedLimit(ctx context.Context, userID int64, peer domain.Peer) error {
current, err := s.GetPeerDialogs(ctx, userID, []domain.Peer{peer})
if err != nil {
return err
}
folderID := domain.DialogMainFolderID
for _, dialog := range current.Dialogs {
if dialog.Peer != peer {
continue
}
if dialog.Pinned {
// 重复 pin 幂等,不占新名额。
return nil
}
folderID = dialog.FolderID
break
}
premium := s.premium != nil && s.premium(ctx, userID)
limit := domain.PinnedDialogsLimit(folderID, premium)
pinnedList, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: limit,
})
if err != nil {
return err
}
if pinnedList.Count >= limit {
return domain.ErrPinnedDialogsTooMuch
}
return nil
}
// ToggleArchivePinned 置顶/取消置顶 archive folder 行本身
// (toggleDialogPin(inputDialogPeerFolder)),返回是否变化。
func (s *Service) ToggleArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return false, nil
}
changed, err := s.dialogs.SetArchivePinned(ctx, userID, pinned)
if err == nil && changed {
s.invalidateDialogListHashes(userID)
}
return changed, err
}
// ReorderPinned 重排指定 folder(0 主列表/1 归档)内的置顶顺序;
// force 只清除该 folder 内不在 order 中的置顶,绝不跨 folder 误伤。
func (s *Service) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
if s == nil || userID == 0 {
return false, nil
}
changed := false
if s.dialogs != nil {
if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil {
return err
privateChanged, err := s.dialogs.ReorderPinned(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if privateChanged {
changed = true
for _, peer := range order {
if peer.Type != domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
if s.channels != nil {
if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil {
return err
channelChanged, err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if channelChanged {
changed = true
for _, peer := range order {
if peer.Type == domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
return nil
if changed && force {
// force may unpin peers omitted from order; the store returns only a boolean,
// so flush the small peer-dialog snapshot cache to avoid stale omitted peers.
s.FlushReadModelCache()
}
return changed, nil
}
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
@ -349,12 +659,20 @@ func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer
if s.channels == nil {
return false, nil
}
return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
changed, err := s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
default:
if s.dialogs == nil {
return false, nil
}
return s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
changed, err := s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
}
@ -384,7 +702,11 @@ func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer do
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
changed, err := s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
@ -405,21 +727,33 @@ func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder dom
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
return s.dialogs.UpsertFolder(ctx, userID, folder)
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
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)
if err := s.dialogs.DeleteFolder(ctx, userID, folderID); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
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)
if err := s.dialogs.ReorderFolders(ctx, userID, order); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
@ -446,11 +780,17 @@ func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []dom
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
return err
}
for _, update := range privatePeers {
s.InvalidateDialog(userID, update.Peer)
}
}
if len(channelPeers) > 0 && s.channels != nil {
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
return err
}
for _, update := range channelPeers {
s.InvalidateDialog(userID, update.Peer)
}
}
return nil
}
@ -591,7 +931,9 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.Messages = append(out.Messages, in.Messages...)
out.ChannelMessages = append(out.ChannelMessages, in.ChannelMessages...)
out.Users = append(out.Users, in.Users...)
out.Channels = append(out.Channels, in.Channels...)
out.Count += in.Count
out.Hash ^= in.Hash
return out

View file

@ -4,12 +4,376 @@ import (
"context"
"errors"
"testing"
"time"
appchannels "telesrv/internal/app/channels"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type countingDialogStore struct {
store.DialogStore
listByUserCalls int
listByPeersCalls int
listByPeersBatches [][]domain.Peer
listDraftsCalls int
}
func (s *countingDialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
s.listByUserCalls++
return s.DialogStore.ListByUser(ctx, userID, filter)
}
func (s *countingDialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
s.listByPeersCalls++
s.listByPeersBatches = append(s.listByPeersBatches, append([]domain.Peer(nil), peers...))
return s.DialogStore.ListByPeers(ctx, userID, peers)
}
func (s *countingDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
s.listDraftsCalls++
return s.DialogStore.ListDrafts(ctx, userID, limit)
}
type fakeDialogReadModelVersions struct {
hashes map[store.ReadModelKey]int64
}
func (f *fakeDialogReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
return hash, hash != 0, nil
}
func (f *fakeDialogReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
if hash := f.hashes[key]; hash != 0 {
out[key] = hash
}
}
return out, nil
}
type countingDialogChannelStore struct {
*memory.ChannelStore
getChannelCalls int
getChannelsCalls int
getChannelDialogsCalls int
}
func (s *countingDialogChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
s.getChannelCalls++
return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID)
}
func (s *countingDialogChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
s.getChannelsCalls++
return s.ChannelStore.GetChannels(ctx, viewerUserID, channelIDs)
}
func (s *countingDialogChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
s.getChannelDialogsCalls++
return s.ChannelStore.GetChannelDialogs(ctx, viewerUserID, channelIDs)
}
func TestGetDialogsHashUsesWarmStableHashCacheAndInvalidatesOnWrite(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
base := memory.NewDialogStore()
if err := base.SaveList(ctx, ownerID, domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: peer,
TopMessage: 7,
TopMessageDate: 70,
UnreadCount: 1,
}},
Messages: []domain.Message{{
ID: 7,
OwnerUserID: ownerID,
Peer: peer,
From: peer,
Date: 70,
Body: "cached top",
}},
Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
counting := &countingDialogStore{DialogStore: base}
dialogs := NewService(counting)
filter := domain.DialogFilter{ExcludePinned: true, Limit: 10}
list, err := dialogs.GetDialogs(ctx, ownerID, filter)
if err != nil {
t.Fatalf("GetDialogs warm: %v", err)
}
if list.Hash == 0 {
t.Fatal("warmed list hash = 0, want stable non-zero hash")
}
check, err := dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash})
if err != nil {
t.Fatalf("GetDialogsHash: %v", err)
}
if !check.Known || !check.Matched || check.Count != list.Count {
t.Fatalf("hash check = %+v, want known matched count %d", check, list.Count)
}
if counting.listByUserCalls != 1 {
t.Fatalf("ListByUser calls = %d, want only warm load", counting.listByUserCalls)
}
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "new draft"}); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
check, err = dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash})
if err != nil {
t.Fatalf("GetDialogsHash after invalidation: %v", err)
}
if check.Known {
t.Fatalf("hash check after invalidation = %+v, want unknown", check)
}
}
func TestSaveDraftNoopsWhenOnlyDateChanges(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
base := memory.NewDialogStore()
dialogs := NewService(base)
changed, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"})
if err != nil {
t.Fatalf("SaveDraft first: %v", err)
}
if !changed {
t.Fatalf("SaveDraft first changed = false, want true")
}
changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "draft"})
if err != nil {
t.Fatalf("SaveDraft same content: %v", err)
}
if changed {
t.Fatalf("SaveDraft same content changed = true, want false")
}
got, found, err := base.GetDraft(ctx, ownerID, peer, 0)
if err != nil || !found {
t.Fatalf("GetDraft = found %v err %v, want stored draft", found, err)
}
if got.Date != 71 {
t.Fatalf("draft date = %d, want original 71", got.Date)
}
changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 73, Message: "updated"})
if err != nil {
t.Fatalf("SaveDraft updated: %v", err)
}
if !changed {
t.Fatalf("SaveDraft updated changed = false, want true")
}
}
func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
base := memory.NewDialogStore()
if err := base.SaveList(ctx, ownerID, domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: peer,
TopMessage: 7,
TopMessageDate: 70,
UnreadCount: 1,
}},
Messages: []domain.Message{{
ID: 7,
OwnerUserID: ownerID,
Peer: peer,
From: peer,
Date: 70,
Body: "cached top",
}},
Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"}); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
counting := &countingDialogStore{DialogStore: base}
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
}}
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
if err != nil {
t.Fatalf("first GetPeerDialogs: %v", err)
}
if len(first.Dialogs) != 1 || first.Dialogs[0].Draft == nil || first.Dialogs[0].Draft.Message != "draft" {
t.Fatalf("first dialog = %+v, want cached draft attached", first.Dialogs)
}
second, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
if err != nil {
t.Fatalf("second GetPeerDialogs: %v", err)
}
if len(second.Dialogs) != 1 || second.Dialogs[0].TopMessage != 7 {
t.Fatalf("second dialog = %+v, want cached top message", second.Dialogs)
}
if counting.listByPeersCalls != 1 || counting.listDraftsCalls != 1 {
t.Fatalf("store calls ListByPeers/ListDrafts = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsCalls)
}
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 202
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
t.Fatalf("third GetPeerDialogs after hash bump: %v", err)
}
if counting.listByPeersCalls != 2 || counting.listDraftsCalls != 2 {
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsCalls)
}
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "new draft"}); err != nil {
t.Fatalf("service SaveDraft: %v", err)
}
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
t.Fatalf("GetPeerDialogs after service invalidation: %v", err)
}
if counting.listByPeersCalls != 3 || counting.listDraftsCalls != 3 {
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsCalls)
}
}
func TestGetPeerDialogsReloadsOnlyReadModelCacheMisses(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
base := memory.NewDialogStore()
if err := base.SaveList(ctx, ownerID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: firstPeer, TopMessage: 7, TopMessageDate: 70},
{Peer: secondPeer, TopMessage: 8, TopMessageDate: 80},
},
Messages: []domain.Message{
{ID: 7, OwnerUserID: ownerID, Peer: firstPeer, From: firstPeer, Date: 70, Body: "first"},
{ID: 8, OwnerUserID: ownerID, Peer: secondPeer, From: secondPeer, Date: 80, Body: "second"},
},
Users: []domain.User{
{ID: firstPeer.ID, AccessHash: 22, FirstName: "First"},
{ID: secondPeer.ID, AccessHash: 33, FirstName: "Second"},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
counting := &countingDialogStore{DialogStore: base}
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: firstPeer.Type, PeerID: firstPeer.ID}: 101,
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}: 202,
}}
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
peers := []domain.Peer{firstPeer, secondPeer}
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil {
t.Fatalf("first GetPeerDialogs: %v", err)
}
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}] = 303
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil {
t.Fatalf("second GetPeerDialogs after one hash bump: %v", err)
}
if counting.listByPeersCalls != 2 {
t.Fatalf("ListByPeers calls = %d, want 2", counting.listByPeersCalls)
}
lastBatch := counting.listByPeersBatches[len(counting.listByPeersBatches)-1]
if len(lastBatch) != 1 || lastBatch[0] != secondPeer {
t.Fatalf("last ListByPeers batch = %+v, want only %+v", lastBatch, secondPeer)
}
}
func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
dialogStore := &countingDialogStore{DialogStore: memory.NewDialogStore()}
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
channels := appchannels.NewService(channelStore)
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Cached Channel Dialog",
Megagroup: true,
Date: 1700003200,
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
sent, err := channels.SendMessage(ctx, ownerID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 11,
Message: "cached channel top",
Date: 1700003210,
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11,
{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22,
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
}}
dialogs := NewService(dialogStore, channelStore).Configure(WithReadModelVersions(versions))
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
if err != nil {
t.Fatalf("first GetPeerDialogs: %v", err)
}
if len(first.Dialogs) != 1 || first.Dialogs[0].TopMessage != sent.Message.ID {
t.Fatalf("first dialogs = %+v, want channel top %d", first.Dialogs, sent.Message.ID)
}
if len(first.ChannelMessages) != 1 || first.ChannelMessages[0].Body != "cached channel top" {
t.Fatalf("first channel messages = %+v, want cached top message", first.ChannelMessages)
}
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
t.Fatalf("second GetPeerDialogs: %v", err)
}
if channelStore.getChannelDialogsCalls != 1 || dialogStore.listDraftsCalls != 1 {
t.Fatalf("store calls GetChannelDialogs/ListDrafts = %d/%d, want 1/1 after channel cache hit",
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
}
versions.hashes[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
t.Fatalf("third GetPeerDialogs after member hash bump: %v", err)
}
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsCalls != 2 {
t.Fatalf("store calls after member hash bump = %d/%d, want 2/2",
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
}
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1700003220, Message: "channel draft"}); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
t.Fatalf("GetPeerDialogs after draft invalidation: %v", err)
}
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsCalls != 3 {
t.Fatalf("store calls after draft invalidation = %d/%d, want 3/3",
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
}
}
func TestDialogPeerReadModelCacheRejectsStaleFillAfterInvalidation(t *testing.T) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
key := dialogPeerCacheKey{userID: 1001, peer: peer}
cache := newDialogPeerReadModelCache(time.Hour)
epoch := cache.cacheEpoch()
cache.invalidate(key)
cache.putIfEpoch(key, domain.DialogList{
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7}},
Hash: 101,
}, 101, epoch)
if _, ok := cache.lookup(key, 101); ok {
t.Fatalf("stale cache fill survived invalidation")
}
}
func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
@ -143,14 +507,14 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
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 {
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 {
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.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{secondPeer, firstPeer}, true); err != nil || changed {
t.Fatalf("ReorderPinned same order = changed %v err %v, want no-op", changed, err)
}
if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed {
t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err)
@ -165,9 +529,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
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)
// 归档对话不再出现在主列表(缺省 folder 视为 folder 0),主列表以
// ArchiveSummary 聚合呈现归档状态。
for _, dialog := range list.Dialogs {
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == first.Channel.ID {
t.Fatalf("archived dialog leaked into main list: %+v", dialog)
}
}
if list.ArchiveSummary == nil {
t.Fatalf("main list archive summary = nil, want attached after archiving")
}
secondDialog := findChannelDialog(t, list, second.Channel.ID)
if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 {
@ -188,9 +558,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
if err != nil {
t.Fatalf("GetDialogs archive: %v", err)
}
if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID {
got := findChannelDialog(t, archived, first.Channel.ID)
if got.FolderID != domain.DialogArchiveFolderID {
t.Fatalf("archived dialog = %+v, want archive folder", got)
}
// 归档清除 pinned(对齐 TDesktop History::setFolderPointer 的本地 unpin),
// unread_mark 保留。
if got.Pinned || got.PinnedOrder != 0 || !got.UnreadMark {
t.Fatalf("archived dialog = %+v, want unpinned with unread mark", got)
}
custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
HasFolderID: true,
@ -206,6 +582,64 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
}
}
func TestTogglePinnedPromotesNewestPinnedAcrossPrivateAndChannelDialogs(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
dialogStore := memory.NewDialogStore()
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore)
dialogs := NewService(dialogStore, channelStore)
privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
if err := dialogStore.SaveList(ctx, ownerID, domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: privatePeer,
TopMessage: 30,
TopMessageDate: 3000,
}},
Messages: []domain.Message{{
ID: 30,
OwnerUserID: ownerID,
Peer: privatePeer,
From: privatePeer,
Date: 3000,
Body: "newer private top",
}},
Users: []domain.User{{ID: privatePeer.ID, AccessHash: 22, FirstName: "Private"}},
}); err != nil {
t.Fatalf("SaveList private dialog: %v", err)
}
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Older Channel",
Megagroup: true,
Date: 1000,
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
if changed, _, err := dialogs.TogglePinned(ctx, ownerID, privatePeer, true); err != nil || !changed {
t.Fatalf("TogglePinned private = changed %v err %v, want changed", changed, err)
}
if changed, _, err := dialogs.TogglePinned(ctx, ownerID, channelPeer, true); err != nil || !changed {
t.Fatalf("TogglePinned channel = changed %v err %v, want changed", changed, err)
}
list, err := dialogs.GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("GetDialogs: %v", err)
}
if len(list.Dialogs) < 2 {
t.Fatalf("dialogs = %+v, want private and channel dialogs", list.Dialogs)
}
if list.Dialogs[0].Peer != channelPeer || list.Dialogs[0].PinnedOrder != 2 {
t.Fatalf("first dialog = %+v, want newly pinned channel with highest order", list.Dialogs[0])
}
if list.Dialogs[1].Peer != privatePeer || list.Dialogs[1].PinnedOrder != 1 {
t.Fatalf("second dialog = %+v, want older pinned private dialog", list.Dialogs[1])
}
}
func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
@ -328,6 +762,99 @@ func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) {
}
}
func TestGetPeerDialogsBatchesMissingChannelPreviews(t *testing.T) {
ctx := context.Background()
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
channels := appchannels.NewService(channelStore)
dialogs := NewService(nil, channelStore)
first, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
Title: "Batch Preview One",
Broadcast: true,
Date: 1700002100,
})
if err != nil {
t.Fatalf("CreateChannel first: %v", err)
}
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
UserID: 1001,
ChannelID: first.Channel.ID,
Username: "batch_preview_one",
}); err != nil {
t.Fatalf("UpdateUsername first: %v", err)
}
firstMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: first.Channel.ID,
RandomID: 101,
Message: "first public preview",
Date: 1700002110,
})
if err != nil {
t.Fatalf("SendMessage first: %v", err)
}
second, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
Title: "Batch Preview Two",
Broadcast: true,
Date: 1700002120,
})
if err != nil {
t.Fatalf("CreateChannel second: %v", err)
}
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
UserID: 1001,
ChannelID: second.Channel.ID,
Username: "batch_preview_two",
}); err != nil {
t.Fatalf("UpdateUsername second: %v", err)
}
secondMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: second.Channel.ID,
RandomID: 102,
Message: "second public preview",
Date: 1700002130,
})
if err != nil {
t.Fatalf("SendMessage second: %v", err)
}
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
Title: "Batch Preview Private",
Broadcast: true,
Date: 1700002140,
})
if err != nil {
t.Fatalf("CreateChannel private: %v", err)
}
channelStore.getChannelCalls = 0
channelStore.getChannelsCalls = 0
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
{Type: domain.PeerTypeChannel, ID: second.Channel.ID},
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
})
if err != nil {
t.Fatalf("GetPeerDialogs batch previews: %v", err)
}
if channelStore.getChannelsCalls != 1 || channelStore.getChannelCalls != 0 {
t.Fatalf("preview channel calls: GetChannels=%d GetChannel=%d, want one batch call only", channelStore.getChannelsCalls, channelStore.getChannelCalls)
}
if len(list.Dialogs) != 2 {
t.Fatalf("dialogs = %+v, want two public previews", list.Dialogs)
}
if got := findChannelDialog(t, list, first.Channel.ID); got.TopMessage != firstMsg.Message.ID {
t.Fatalf("first preview top = %d, want %d", got.TopMessage, firstMsg.Message.ID)
}
if got := findChannelDialog(t, list, second.Channel.ID); got.TopMessage != secondMsg.Message.ID {
t.Fatalf("second preview top = %d, want %d", got.TopMessage, secondMsg.Message.ID)
}
if len(list.ChannelMessages) != 2 {
t.Fatalf("channel messages = %+v, want two top messages", list.ChannelMessages)
}
}
func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog {
t.Helper()
for _, dialog := range list.Dialogs {
@ -361,3 +888,149 @@ func (p dialogProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.Pe
}
return out, nil
}
func TestGetDialogsMainListAttachesArchiveSummary(t *testing.T) {
ctx := context.Background()
dialogStore := memory.NewDialogStore()
dialogs := NewService(dialogStore)
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2003}
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
Peer: archivedPeer,
FolderID: domain.DialogArchiveFolderID,
TopMessage: 7,
TopMessageDate: 30,
UnreadCount: 3,
}); err != nil {
t.Fatalf("upsert archived dialog: %v", err)
}
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
Peer: mainPeer,
TopMessage: 9,
TopMessageDate: 40,
}); err != nil {
t.Fatalf("upsert main dialog: %v", err)
}
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("GetDialogs main: %v", err)
}
if list.ArchiveSummary == nil {
t.Fatalf("main list archive summary = nil, want attached")
}
if list.ArchiveSummary.TopPeer != archivedPeer || list.ArchiveSummary.TopMessage != 7 {
t.Fatalf("archive summary top = %+v, want peer %+v message 7", list.ArchiveSummary, archivedPeer)
}
if list.ArchiveSummary.UnreadPeersCount != 1 || list.ArchiveSummary.UnreadMessagesCount != 3 {
t.Fatalf("archive summary counts = %+v, want 1 peer / 3 messages", list.ArchiveSummary)
}
for _, dialog := range list.Dialogs {
if dialog.Peer == archivedPeer {
t.Fatalf("archived dialog leaked into main list: %+v", dialog)
}
}
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 archived.ArchiveSummary != nil {
t.Fatalf("archive list summary = %+v, want nil", archived.ArchiveSummary)
}
paged, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10, OffsetID: 9})
if err != nil {
t.Fatalf("GetDialogs paged: %v", err)
}
if paged.ArchiveSummary != nil {
t.Fatalf("paged list summary = %+v, want nil (first page only)", paged.ArchiveSummary)
}
}
func TestGetDialogsMainListSkipsArchiveSummaryWhenEmpty(t *testing.T) {
ctx := context.Background()
dialogStore := memory.NewDialogStore()
dialogs := NewService(dialogStore)
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
TopMessage: 9,
TopMessageDate: 40,
}); err != nil {
t.Fatalf("upsert main dialog: %v", err)
}
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("GetDialogs: %v", err)
}
if list.ArchiveSummary != nil {
t.Fatalf("archive summary = %+v, want nil when no archived dialogs", list.ArchiveSummary)
}
}
func TestGetDialogsPinnedOnlyAttachesArchiveSummary(t *testing.T) {
ctx := context.Background()
dialogStore := memory.NewDialogStore()
dialogs := NewService(dialogStore)
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
Peer: archivedPeer,
FolderID: domain.DialogArchiveFolderID,
TopMessage: 7,
TopMessageDate: 30,
UnreadCount: 3,
}); err != nil {
t.Fatalf("upsert archived dialog: %v", err)
}
// getPinnedDialogs(folder_id=0) 路径:DrKLO 的 archive 行发现完全依赖
// 该响应里的 dialogFolder 条目。
pinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: domain.DialogMainFolderID,
Limit: 100,
})
if err != nil {
t.Fatalf("GetDialogs pinned: %v", err)
}
if pinned.ArchiveSummary == nil || !pinned.ArchiveSummary.Pinned || pinned.ArchiveSummary.TopPeer != archivedPeer {
t.Fatalf("pinned archive summary = %+v, want pinned archive entry", pinned.ArchiveSummary)
}
// archive 行被 unpin 后不属于 pinned 集合。
if _, err := dialogStore.SetArchivePinned(ctx, 1001, false); err != nil {
t.Fatalf("set archive pinned: %v", err)
}
unpinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: domain.DialogMainFolderID,
Limit: 100,
})
if err != nil {
t.Fatalf("GetDialogs pinned after unpin: %v", err)
}
if unpinned.ArchiveSummary != nil {
t.Fatalf("pinned archive summary after unpin = %+v, want nil", unpinned.ArchiveSummary)
}
// 主列表第一页仍输出条目(pinned flag 用真值),与官方一致。
main, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("GetDialogs main: %v", err)
}
if main.ArchiveSummary == nil || main.ArchiveSummary.Pinned {
t.Fatalf("main archive summary = %+v, want entry with pinned=false", main.ArchiveSummary)
}
// exclude_pinned 请求按官方语义不带条目。
excluded, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ExcludePinned: true, Limit: 10})
if err != nil {
t.Fatalf("GetDialogs exclude pinned: %v", err)
}
if excluded.ArchiveSummary != nil {
t.Fatalf("exclude_pinned archive summary = %+v, want nil", excluded.ArchiveSummary)
}
}

View file

@ -0,0 +1,282 @@
package files
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"telesrv/internal/domain"
"telesrv/internal/seed/appearance"
)
// AppearanceSeedStats reports default appearance resources imported into media storage.
type AppearanceSeedStats struct {
Wallpapers int
Documents int
Blobs int
Skipped bool
}
// SeedAppearance imports the default wallpaper document catalog into telesrv media storage.
func (s *Service) SeedAppearance(ctx context.Context) (AppearanceSeedStats, error) {
var stats AppearanceSeedStats
catalog := appearance.Default()
if len(catalog.Wallpapers) == 0 && len(catalog.ChatThemes) == 0 {
stats.Skipped = true
return stats, nil
}
stateHash, err := s.seedAppearanceStateHash()
if err != nil {
return stats, err
}
ready, err := s.appearanceSeedReady(ctx, catalog)
if err != nil {
return stats, err
}
matched, err := s.seedStateMatches(ctx, seedAppearanceStateKey, stateHash)
if err != nil {
return stats, err
}
if matched && ready {
stats.Wallpapers = len(catalog.Wallpapers)
stats.Skipped = true
return stats, nil
}
seen := make(map[int64]bool)
seedDoc := func(in appearance.Document, label string) error {
if in.ID == 0 || seen[in.ID] {
return nil
}
seen[in.ID] = true
doc, blobs, err := s.seedAppearanceDocument(ctx, in)
if err != nil {
return fmt.Errorf("seed %s %d: %w", label, in.ID, err)
}
if doc.ID != 0 {
stats.Documents++
}
stats.Blobs += blobs
return nil
}
for _, wallpaper := range catalog.Wallpapers {
if err := seedDoc(wallpaper.Document, "wallpaper"); err != nil {
return stats, err
}
stats.Wallpapers++
}
// 聊天主题的主题背景墙纸文档也要进媒体库,否则客户端取主题背景会 404。
for _, ct := range catalog.ChatThemes {
for _, setting := range ct.Settings {
if err := seedDoc(setting.Wallpaper.Document, "chat theme wallpaper"); err != nil {
return stats, err
}
}
}
if err := s.putSeedState(ctx, seedAppearanceStateKey, stateHash); err != nil {
return stats, err
}
return stats, nil
}
func (s *Service) seedAppearanceStateHash() (string, error) {
raw, err := appearance.FS.ReadFile("default_appearance_seed.json")
if err != nil {
return "", err
}
return seedStateHash(func(h hash.Hash) error {
writeSeedStateHeader(h, seedAppearanceStateVersion, s.dc)
_, _ = h.Write(raw)
return nil
})
}
func (s *Service) appearanceSeedReady(ctx context.Context, catalog appearance.Catalog) (bool, error) {
docs := make(map[int64]appearance.Document)
add := func(doc appearance.Document) {
if doc.ID != 0 {
docs[doc.ID] = doc
}
}
for _, wallpaper := range catalog.Wallpapers {
add(wallpaper.Document)
}
for _, ct := range catalog.ChatThemes {
for _, setting := range ct.Settings {
add(setting.Wallpaper.Document)
}
}
if len(docs) == 0 {
return true, nil
}
ids := make([]int64, 0, len(docs))
locationKeys := make([]string, 0, len(docs)*2)
for id, doc := range docs {
ids = append(ids, id)
if doc.Path != "" {
locationKeys = append(locationKeys, fmt.Sprintf("doc:%d", id))
}
for _, thumb := range doc.Thumbs {
if thumb.Path != "" && thumb.Type != "" {
locationKeys = append(locationKeys, fmt.Sprintf("doc:%d:%s", id, thumb.Type))
}
}
}
stored, err := s.media.GetDocuments(ctx, ids)
if err != nil {
return false, err
}
if len(stored) < len(docs) {
return false, nil
}
for _, doc := range stored {
want, ok := docs[doc.ID]
if !ok {
continue
}
if doc.DCID != s.dc || doc.MimeType != want.MimeType || doc.Size != want.Size {
return false, nil
}
delete(docs, doc.ID)
}
if len(docs) > 0 {
return false, nil
}
if len(locationKeys) == 0 {
return true, nil
}
blobs, err := s.media.GetFileBlobs(ctx, locationKeys)
if err != nil {
return false, err
}
for _, key := range locationKeys {
if _, ok := blobs[key]; !ok {
return false, nil
}
}
return true, nil
}
func (s *Service) seedAppearanceDocument(ctx context.Context, in appearance.Document) (domain.Document, int, error) {
if in.ID == 0 {
return domain.Document{}, 0, nil
}
doc := domain.Document{
ID: in.ID,
AccessHash: in.AccessHash,
Date: in.Date,
MimeType: in.MimeType,
Size: in.Size,
DCID: s.dc,
Attributes: appearanceDocumentAttributes(in.Attributes),
Thumbs: appearanceDocumentThumbs(in.Thumbs),
}
blobs := 0
if in.Path != "" {
data, sum, err := readAppearanceSeedBlob(in.Path, in.SHA256)
if err != nil {
return domain.Document{}, blobs, err
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, blobs, err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", in.ID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
SHA256: sum,
MimeType: in.MimeType,
}); err != nil {
return domain.Document{}, blobs, err
}
s.prewarmSmallBlob(objectKey, data)
blobs++
}
for _, thumb := range in.Thumbs {
if thumb.Path == "" || thumb.Type == "" {
continue
}
data, sum, err := readAppearanceSeedBlob(thumb.Path, thumb.SHA256)
if err != nil {
return domain.Document{}, blobs, err
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, blobs, err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", in.ID, thumb.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
SHA256: sum,
MimeType: seedThumbMimeType(data),
}); err != nil {
return domain.Document{}, blobs, err
}
s.prewarmSmallBlob(objectKey, data)
blobs++
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, blobs, err
}
return doc, blobs, nil
}
func readAppearanceSeedBlob(path, wantSHA string) ([]byte, []byte, error) {
data, err := appearance.FS.ReadFile(path)
if err != nil {
return nil, nil, err
}
sum := sha256.Sum256(data)
got := hex.EncodeToString(sum[:])
if wantSHA != "" && got != wantSHA {
return nil, nil, fmt.Errorf("%s sha256 = %s, want %s", path, got, wantSHA)
}
return data, append([]byte(nil), sum[:]...), nil
}
func appearanceDocumentAttributes(in []appearance.DocumentAttribute) []domain.DocumentAttribute {
out := make([]domain.DocumentAttribute, 0, len(in))
for _, attr := range in {
switch attr.Kind {
case "image_size":
out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: attr.W,
H: attr.H,
})
case "filename":
if attr.FileName != "" {
out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrFilename,
FileName: attr.FileName,
})
}
}
}
return out
}
func appearanceDocumentThumbs(in []appearance.PhotoSize) []domain.PhotoSize {
out := make([]domain.PhotoSize, 0, len(in))
for _, thumb := range in {
if thumb.Type == "" {
continue
}
switch thumb.Kind {
case "size":
out = append(out, domain.PhotoSize{
Kind: domain.PhotoSizeKindDefault,
Type: thumb.Type,
W: thumb.W,
H: thumb.H,
Size: thumb.Size,
})
}
}
return out
}

View file

@ -0,0 +1,98 @@
package files
import (
"context"
"fmt"
"testing"
"telesrv/internal/seed/appearance"
)
func TestSeedAppearanceImportsDefaultWallpaperDocuments(t *testing.T) {
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
stats, err := svc.SeedAppearance(context.Background())
if err != nil {
t.Fatalf("SeedAppearance: %v", err)
}
if stats.Skipped || stats.Wallpapers == 0 || stats.Documents == 0 || stats.Blobs < stats.Documents {
t.Fatalf("SeedAppearance stats = %+v, want non-empty wallpapers/documents with >=1 blob each", stats)
}
var first appearance.Wallpaper
for _, w := range appearance.Default().Wallpapers {
if w.Document.ID != 0 {
first = w
break
}
}
if first.Document.ID == 0 {
t.Fatalf("no wallpaper with a document in catalog")
}
doc, ok, err := media.GetDocument(context.Background(), first.Document.ID)
if err != nil || !ok {
t.Fatalf("GetDocument(%d) = ok %v err %v", first.Document.ID, ok, err)
}
if doc.DCID != 2 || doc.MimeType != first.Document.MimeType || doc.Size != first.Document.Size {
t.Fatalf("document = dc %d mime %q size %d, want dc 2 mime %q size %d",
doc.DCID, doc.MimeType, doc.Size, first.Document.MimeType, first.Document.Size)
}
if len(doc.Thumbs) == 0 || doc.Thumbs[0].Type != "m" {
t.Fatalf("document thumbs = %+v, want m thumbnail", doc.Thumbs)
}
if _, ok, err := media.GetFileBlob(context.Background(), fmt.Sprintf("doc:%d", first.Document.ID)); err != nil || !ok {
t.Fatalf("main blob ok=%v err=%v, want present", ok, err)
}
if _, ok, err := media.GetFileBlob(context.Background(), fmt.Sprintf("doc:%d:m", first.Document.ID)); err != nil || !ok {
t.Fatalf("thumb blob ok=%v err=%v, want present", ok, err)
}
}
func TestSeedAppearanceSkipsUnchangedCatalogAndRepairsMissingBlob(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
first, err := svc.SeedAppearance(ctx)
if err != nil {
t.Fatalf("first SeedAppearance: %v", err)
}
if first.Skipped || first.Documents == 0 || first.Blobs == 0 {
t.Fatalf("first stats = %+v, want import", first)
}
second, err := svc.SeedAppearance(ctx)
if err != nil {
t.Fatalf("second SeedAppearance: %v", err)
}
if !second.Skipped || second.Documents != 0 || second.Blobs != 0 {
t.Fatalf("second stats = %+v, want unchanged catalog skip", second)
}
var firstDocID int64
for _, w := range appearance.Default().Wallpapers {
if w.Document.ID != 0 && w.Document.Path != "" {
firstDocID = w.Document.ID
break
}
}
if firstDocID == 0 {
t.Fatal("no wallpaper document found")
}
delete(media.blobs, fmt.Sprintf("doc:%d", firstDocID))
repaired, err := svc.SeedAppearance(ctx)
if err != nil {
t.Fatalf("repair SeedAppearance: %v", err)
}
if repaired.Skipped || repaired.Documents == 0 || repaired.Blobs == 0 {
t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired)
}
}

View file

@ -2,11 +2,74 @@ package files
import (
"container/list"
"strconv"
"sync"
"time"
"telesrv/internal/domain"
)
// stickerSetNegativeCache 是「按 ref 查不到的贴纸集」的短 TTL 负缓存。未 seed 的 short_name
// 集合会被客户端反复 getStickerSet(每次都打一发 PG GetStickerSetByShortName),这里缓存
// not-found 结果,TTL 内直接短路、不再查库。TTL 短(自愈):运营/运行时新增的集合最多 TTL 后
// 才被解析,避免负缓存长期遮住真实存在的集合。
type stickerSetNegativeCache struct {
mu sync.Mutex
ttl time.Duration
entries map[string]time.Time
}
const stickerSetNegativeCacheMaxEntries = 100000
func newStickerSetNegativeCache(ttl time.Duration) *stickerSetNegativeCache {
return &stickerSetNegativeCache{ttl: ttl, entries: map[string]time.Time{}}
}
func stickerSetRefKey(ref domain.StickerSetRef) string {
switch ref.Kind {
case domain.StickerSetRefByID:
return "id:" + strconv.FormatInt(ref.ID, 10)
case domain.StickerSetRefByShortName:
return "short:" + ref.ShortName
case domain.StickerSetRefBySystem:
return "sys:" + ref.SystemKey
default:
return ""
}
}
func (c *stickerSetNegativeCache) has(ref domain.StickerSetRef) bool {
key := stickerSetRefKey(ref)
if key == "" {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
exp, ok := c.entries[key]
if !ok {
return false
}
if time.Now().After(exp) {
delete(c.entries, key)
return false
}
return true
}
func (c *stickerSetNegativeCache) put(ref domain.StickerSetRef) {
key := stickerSetRefKey(ref)
if key == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
// 简单上限防无界增长:超限整表清空(短 TTL 下冷启代价可忽略)。
if len(c.entries) >= stickerSetNegativeCacheMaxEntries {
c.entries = make(map[string]time.Time, 1024)
}
c.entries[key] = time.Now().Add(c.ttl)
}
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
// reaction/头像更被大量用户重复拉)。

View file

@ -1,13 +1,20 @@
package files
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"telesrv/internal/domain"
)
// BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。
@ -15,15 +22,42 @@ import (
type BlobBackend interface {
Name() string
Put(ctx context.Context, data []byte) (objectKey string, err error)
PutReader(ctx context.Context, r io.Reader) (objectKey string, size int64, sha256 []byte, 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)
}
// UploadPartBackend 保存 upload.saveFilePart/saveBigFilePart 的临时分片字节。
// 与正式 blob 不同,上传分片 key 唯一且可删除,成功组装/覆盖重传/GC 后必须清理。
type UploadPartBackend interface {
PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error)
GetUploadPart(ctx context.Context, objectKey string) ([]byte, error)
OpenUploadPart(ctx context.Context, objectKey string) (io.ReadCloser, error)
DeleteUploadPart(ctx context.Context, objectKey string) error
DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error)
}
type uploadPartObject struct {
Backend domain.MediaBackend
ObjectKey string
Size int64
SHA256 []byte
}
// LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。
type LocalFS struct {
root string
mu sync.Mutex
openBlobFiles map[string]*sharedBlobFile
}
type sharedBlobFile struct {
key string
file *os.File
refs int
}
// NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。
@ -34,7 +68,7 @@ func NewLocalFS(root string) (*LocalFS, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create blob root %q: %w", root, err)
}
return &LocalFS{root: root}, nil
return &LocalFS{root: root, openBlobFiles: make(map[string]*sharedBlobFile)}, nil
}
// Name 返回后端标识,与 file_blobs.backend 一致。
@ -48,24 +82,90 @@ func (l *LocalFS) pathFor(objectKey string) string {
}
// Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。
func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
func (l *LocalFS) Put(ctx context.Context, data []byte) (string, error) {
key, _, _, err := l.PutReader(ctx, bytes.NewReader(data))
return key, err
}
// PutReader 流式写入内容,边复制边计算 sha256,避免上层为大视频先拼出完整 []byte。
func (l *LocalFS) PutReader(ctx context.Context, r io.Reader) (string, int64, []byte, error) {
tmpDir := filepath.Join(l.root, "_tmp")
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return "", 0, nil, fmt.Errorf("create blob tmp dir: %w", err)
}
tmp, err := os.CreateTemp(tmpDir, "blob-*.tmp")
if err != nil {
return "", 0, nil, fmt.Errorf("create blob tmp file: %w", err)
}
tmpPath := tmp.Name()
committed := false
defer func() {
if !committed {
_ = os.Remove(tmpPath)
}
}()
h := sha256.New()
size, err := copyWithContext(ctx, io.MultiWriter(tmp, h), r)
closeErr := tmp.Close()
if err != nil {
return "", 0, nil, fmt.Errorf("write blob stream: %w", err)
}
if closeErr != nil {
return "", 0, nil, fmt.Errorf("close blob stream: %w", closeErr)
}
sum := h.Sum(nil)
key := hex.EncodeToString(sum)
path := l.pathFor(key)
if _, err := os.Stat(path); err == nil {
return key, nil
committed = true
_ = os.Remove(tmpPath)
return key, size, append([]byte(nil), sum...), nil
} else if err != nil && !os.IsNotExist(err) {
return "", 0, nil, fmt.Errorf("stat blob: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("create blob dir: %w", err)
return "", 0, nil, 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(tmpPath, path); err != nil {
if _, statErr := os.Stat(path); statErr == nil {
committed = true
_ = os.Remove(tmpPath)
return key, size, append([]byte(nil), sum...), nil
}
return "", 0, nil, fmt.Errorf("commit blob: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return "", fmt.Errorf("commit blob: %w", err)
committed = true
return key, size, append([]byte(nil), sum...), nil
}
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
buf := make([]byte, 256<<10)
var written int64
for {
select {
case <-ctx.Done():
return written, ctx.Err()
default:
}
n, readErr := src.Read(buf)
if n > 0 {
w, writeErr := dst.Write(buf[:n])
written += int64(w)
if writeErr != nil {
return written, writeErr
}
if w != n {
return written, io.ErrShortWrite
}
}
if readErr == io.EOF {
return written, nil
}
if readErr != nil {
return written, readErr
}
}
return key, nil
}
// Get 读取 objectKey 对应的全部字节。
@ -76,11 +176,12 @@ func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) {
// 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))
blobFile, err := l.openBlobFile(objectKey)
if err != nil {
return nil, 0, err
}
defer f.Close()
defer l.releaseBlobFile(blobFile)
f := blobFile.file
info, err := f.Stat()
if err != nil {
return nil, 0, err
@ -103,3 +204,157 @@ func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit in
}
return buf[:read], total, nil
}
func (l *LocalFS) openBlobFile(objectKey string) (*sharedBlobFile, error) {
l.mu.Lock()
defer l.mu.Unlock()
if f, ok := l.openBlobFiles[objectKey]; ok {
f.refs++
return f, nil
}
f, err := os.Open(l.pathFor(objectKey))
if err != nil {
return nil, err
}
blobFile := &sharedBlobFile{
key: objectKey,
file: f,
refs: 1,
}
l.openBlobFiles[objectKey] = blobFile
return blobFile, nil
}
func (l *LocalFS) releaseBlobFile(blobFile *sharedBlobFile) {
if blobFile == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if blobFile.refs > 0 {
blobFile.refs--
}
if blobFile.refs != 0 {
return
}
if current := l.openBlobFiles[blobFile.key]; current == blobFile {
delete(l.openBlobFiles, blobFile.key)
}
_ = blobFile.file.Close()
}
func (l *LocalFS) PutUploadPart(_ context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) {
sum := sha256.Sum256(data)
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return uploadPartObject{}, fmt.Errorf("generate upload part key: %w", err)
}
key := filepath.ToSlash(filepath.Join(
"upload_parts",
fmt.Sprintf("%d", ownerUserID),
fmt.Sprintf("%d", fileID),
fmt.Sprintf("%06d-%s.part", part, hex.EncodeToString(nonce[:])),
))
path, err := l.uploadPartPath(key)
if err != nil {
return uploadPartObject{}, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return uploadPartObject{}, fmt.Errorf("create upload part dir: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return uploadPartObject{}, fmt.Errorf("write upload part: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return uploadPartObject{}, fmt.Errorf("commit upload part: %w", err)
}
return uploadPartObject{
Backend: domain.MediaBackend(l.Name()),
ObjectKey: key,
Size: int64(len(data)),
SHA256: append([]byte(nil), sum[:]...),
}, nil
}
func (l *LocalFS) GetUploadPart(_ context.Context, objectKey string) ([]byte, error) {
path, err := l.uploadPartPath(objectKey)
if err != nil {
return nil, err
}
return os.ReadFile(path)
}
func (l *LocalFS) OpenUploadPart(_ context.Context, objectKey string) (io.ReadCloser, error) {
path, err := l.uploadPartPath(objectKey)
if err != nil {
return nil, err
}
return os.Open(path)
}
func (l *LocalFS) DeleteUploadPart(_ context.Context, objectKey string) error {
path, err := l.uploadPartPath(objectKey)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete upload part: %w", err)
}
return nil
}
func (l *LocalFS) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) {
if limit <= 0 {
return 0, nil
}
root := filepath.Join(l.root, "upload_parts")
if _, err := os.Stat(root); os.IsNotExist(err) {
return 0, nil
} else if err != nil {
return 0, fmt.Errorf("stat upload parts root: %w", err)
}
var deleted int64
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if deleted >= int64(limit) {
return filepath.SkipAll
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if !info.ModTime().Before(before) {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
deleted++
return nil
})
if err != nil {
return deleted, fmt.Errorf("delete expired upload part objects: %w", err)
}
return deleted, nil
}
func (l *LocalFS) uploadPartPath(objectKey string) (string, error) {
clean := filepath.Clean(filepath.FromSlash(objectKey))
prefix := "upload_parts" + string(os.PathSeparator)
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || !strings.HasPrefix(clean, prefix) {
return "", fmt.Errorf("invalid upload part object key %q", objectKey)
}
return filepath.Join(l.root, clean), nil
}

View file

@ -3,7 +3,11 @@ package files
import (
"bytes"
"context"
"os"
"strings"
"sync"
"testing"
"time"
)
func TestLocalFSPutGetRoundTrip(t *testing.T) {
@ -40,6 +44,68 @@ func TestLocalFSPutGetRoundTrip(t *testing.T) {
}
}
func TestLocalFSPutReaderRoundTrip(t *testing.T) {
fs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local fs: %v", err)
}
ctx := context.Background()
data := strings.Repeat("streamed-", 1024)
key, size, sum, err := fs.PutReader(ctx, strings.NewReader(data))
if err != nil {
t.Fatalf("put reader: %v", err)
}
if key == "" || size != int64(len(data)) || len(sum) != 32 {
t.Fatalf("stream metadata key=%q size=%d sha=%d", key, size, len(sum))
}
got, err := fs.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
if string(got) != data {
t.Fatalf("roundtrip mismatch")
}
}
func TestLocalFSDeleteExpiredUploadParts(t *testing.T) {
fs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local fs: %v", err)
}
ctx := context.Background()
oldPart, err := fs.PutUploadPart(ctx, 10, 100, 0, []byte("old"))
if err != nil {
t.Fatalf("put old upload part: %v", err)
}
freshPart, err := fs.PutUploadPart(ctx, 10, 100, 1, []byte("fresh"))
if err != nil {
t.Fatalf("put fresh upload part: %v", err)
}
oldPath, err := fs.uploadPartPath(oldPart.ObjectKey)
if err != nil {
t.Fatalf("old upload part path: %v", err)
}
oldTime := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("age old upload part: %v", err)
}
deleted, err := fs.DeleteExpiredUploadParts(ctx, time.Now().Add(-24*time.Hour), 10)
if err != nil {
t.Fatalf("delete expired upload parts: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted = %d, want 1", deleted)
}
if _, err := fs.GetUploadPart(ctx, oldPart.ObjectKey); err == nil {
t.Fatalf("old upload part still exists")
}
if data, err := fs.GetUploadPart(ctx, freshPart.ObjectKey); err != nil || string(data) != "fresh" {
t.Fatalf("fresh upload part = %q err=%v", data, err)
}
}
func TestLocalFSDistinctContent(t *testing.T) {
fs, err := NewLocalFS(t.TempDir())
if err != nil {
@ -90,3 +156,143 @@ func TestLocalFSGetRange(t *testing.T) {
})
}
}
func TestLocalFSReusesOpenBlobFileWhileActive(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)
}
first, err := fs.openBlobFile(key)
if err != nil {
t.Fatalf("open first: %v", err)
}
second, err := fs.openBlobFile(key)
if err != nil {
t.Fatalf("open second: %v", err)
}
firstReleased, secondReleased := false, false
t.Cleanup(func() {
if !firstReleased {
fs.releaseBlobFile(first)
}
if !secondReleased {
fs.releaseBlobFile(second)
}
})
if first != second {
t.Fatal("same active blob should reuse one open file handle")
}
fs.mu.Lock()
open, refs := len(fs.openBlobFiles), first.refs
fs.mu.Unlock()
if open != 1 || refs != 2 {
t.Fatalf("active files=%d refs=%d, want 1/2", open, refs)
}
fs.releaseBlobFile(first)
firstReleased = true
fs.mu.Lock()
open, refs = len(fs.openBlobFiles), second.refs
fs.mu.Unlock()
if open != 1 || refs != 1 {
t.Fatalf("after first release active files=%d refs=%d, want 1/1", open, refs)
}
var buf [4]byte
if n, err := second.file.ReadAt(buf[:], 3); err != nil || n != len(buf) || string(buf[:]) != "3456" {
t.Fatalf("shared file ReadAt n=%d err=%v bytes=%q, want 3456", n, err, buf[:])
}
fs.releaseBlobFile(second)
secondReleased = true
fs.mu.Lock()
open = len(fs.openBlobFiles)
fs.mu.Unlock()
if open != 0 {
t.Fatalf("after final release active files=%d, want 0", open)
}
}
func TestLocalFSReusesOpenBlobFileUnderConcurrentOpen(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(strings.Repeat("x", 1024)))
if err != nil {
t.Fatalf("put: %v", err)
}
const readers = 32
start := make(chan struct{})
files := make([]*sharedBlobFile, readers)
errs := make([]error, readers)
var wg sync.WaitGroup
for i := range files {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
files[i], errs[i] = fs.openBlobFile(key)
}(i)
}
close(start)
wg.Wait()
released := false
t.Cleanup(func() {
if released {
return
}
for _, f := range files {
if f != nil {
fs.releaseBlobFile(f)
}
}
})
for i, err := range errs {
if err != nil {
t.Fatalf("open %d: %v", i, err)
}
}
first := files[0]
if first == nil {
t.Fatal("first open returned nil")
}
for i, f := range files {
if f != first {
t.Fatalf("file %d = %p, want shared %p", i, f, first)
}
}
fs.mu.Lock()
open, refs := len(fs.openBlobFiles), first.refs
fs.mu.Unlock()
if open != 1 || refs != readers {
t.Fatalf("active files=%d refs=%d, want 1/%d", open, refs, readers)
}
wg = sync.WaitGroup{}
for _, f := range files {
wg.Add(1)
go func(f *sharedBlobFile) {
defer wg.Done()
fs.releaseBlobFile(f)
}(f)
}
wg.Wait()
released = true
fs.mu.Lock()
open = len(fs.openBlobFiles)
fs.mu.Unlock()
if open != 0 {
t.Fatalf("after concurrent release active files=%d, want 0", open)
}
}

View file

@ -0,0 +1,148 @@
package files
import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
)
// 合成集的固定 ID/AccessHash:种子导出的真实集 ID 在 1.2e15 量级,取明显隔离的
// 常量段避免撞键;幂等性由 system_key 查询保证,常量仅需稳定。
const (
defaultEmojiStatusSetID int64 = 7_777_000_000_000_001
defaultEmojiStatusSetAccessHash int64 = 7_777_000_000_000_002
)
// defaultEmojiStatusEmoticons 是默认状态的精选 emoji(对齐官方默认状态选盘的
// 常见项),按展示顺序排列。匹配不到的 emoticon 静默跳过(取决于 seed 内容)。
var defaultEmojiStatusEmoticons = []string{
"\U0001f4bc", // 💼 工作
"\U0001f393", // 🎓 学习
"\U0001f3e0", // 🏠 在家
"\U0001f334", // 🌴 度假
"\U0001f3d6", // 🏖 海滩
"✈", // ✈️ 旅行
"\U0001f912", // 🤒 生病
"\U0001f634", // 😴 睡觉
"☕", // ☕ 咖啡
"\U0001f4bb", // 💻 编码/办公
"\U0001f4da", // 📚 阅读
"\U0001f3ae", // 🎮 游戏
"\U0001f3a7", // 🎧 听歌
"⚽", // ⚽ 运动
"\U0001f3c6", // 🏆 获胜
"❤", // ❤️ 爱心
"\U0001f60e", // 😎 酷
"\U0001f319", // 🌙 勿扰
"⭐", // ⭐ 星标
"\U0001f525", // 🔥 火
"\U0001f44d", // 👍 赞
"\U0001f389", // 🎉 庆祝
"\U0001f914", // 🤔 思考
"\U0001f607", // 😇 天使
"\U0001f973", // 🥳 派对
"\U0001f602", // 😂 大笑
"\U0001f970", // 🥰 喜爱
"\U0001f62d", // 😭 大哭
"\U0001f92f", // 🤯 爆炸
"\U0001f440", // 👀 围观
"\U0001f4af", // 💯 满分
"\U0001f64f", // 🙏 感谢
"\U0001f91d", // 🤝 合作
"✍", // ✍️ 写作
"\U0001f697", // 🚗 通勤
"\U0001f355", // 🍕 干饭
"\U0001f382", // 🎂 生日
"\U0001f338", // 🌸 春天
"⛄", // ⛄ 冬天
"\U0001f984", // 🦄 独角兽
}
// EnsureDefaultEmojiStatusSet 幂等地合成默认 emoji status 系统集:从已 seed 的
// animated_emoji 系统集按 emoticon 精选文档(复用文档行与 blob,不复制字节)。
// 返回 (集内文档数, 是否本次新建)。animated_emoji 未 seed 时静默跳过。
func (s *Service) EnsureDefaultEmojiStatusSet(ctx context.Context) (int, bool, error) {
if existing, found, err := s.media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses); err != nil {
return 0, false, fmt.Errorf("lookup default emoji status set: %w", err)
} else if found {
return len(existing.DocumentIDs), false, nil
}
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
if err != nil {
return 0, false, fmt.Errorf("lookup animated_emoji set: %w", err)
}
if !found || len(source.Packs) == 0 {
return 0, false, nil
}
byEmoticon := make(map[string][]int64, len(source.Packs))
for _, pack := range source.Packs {
key := normalizeStatusEmoticon(pack.Emoticon)
if key == "" || len(pack.DocumentIDs) == 0 {
continue
}
byEmoticon[key] = append(byEmoticon[key], pack.DocumentIDs...)
}
set := domain.StickerSet{
ID: defaultEmojiStatusSetID,
AccessHash: defaultEmojiStatusSetAccessHash,
ShortName: "TelesrvDefaultStatuses",
Title: "Default Emoji Statuses",
Kind: domain.StickerSetKindSystem,
SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses,
Official: true,
Animated: source.Animated,
Emojis: true,
}
seen := make(map[int64]struct{})
for _, emoticon := range defaultEmojiStatusEmoticons {
ids := byEmoticon[normalizeStatusEmoticon(emoticon)]
if len(ids) == 0 {
continue
}
pack := domain.StickerPack{Emoticon: emoticon}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
set.DocumentIDs = append(set.DocumentIDs, id)
pack.DocumentIDs = append(pack.DocumentIDs, id)
}
if len(pack.DocumentIDs) > 0 {
set.Packs = append(set.Packs, pack)
}
}
if len(set.DocumentIDs) == 0 {
return 0, false, nil
}
set.Count = len(set.DocumentIDs)
set.Hash = stickerSetDocsHash(set.DocumentIDs)
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
if err != nil {
return 0, false, fmt.Errorf("load default emoji status documents: %w", err)
}
if err := s.media.PutStickerSet(ctx, set); err != nil {
return 0, false, fmt.Errorf("persist default emoji status set: %w", err)
}
s.stickerSetCache.put(set, orderDocuments(docs, set.DocumentIDs))
return set.Count, true, nil
}
// normalizeStatusEmoticon 统一变体选择符差异("❤" vs "❤️"),匹配 seed packs 与
// 精选清单两侧的书写形态。
func normalizeStatusEmoticon(e string) string {
return strings.ReplaceAll(strings.TrimSpace(e), "️", "")
}
// stickerSetDocsHash 由文档 ID 列表算稳定 set hash(messages.getStickerSet 与
// account.getDefaultEmojiStatuses 共用一份缓存判定)。
func stickerSetDocsHash(ids []int64) int {
var h uint64
for _, id := range ids {
h ^= uint64(id)
h = h*0x4f25 + uint64(id)
}
return int(h & 0x7fffffff)
}

View file

@ -0,0 +1,117 @@
package files
import (
"context"
"testing"
"telesrv/internal/domain"
)
func defaultStatusesTestService(t *testing.T) (*Service, *fakeMediaStore) {
t.Helper()
media := newFakeMediaStore()
return NewService(media, nil, 2), media
}
func putAnimatedEmojiSet(t *testing.T, media *fakeMediaStore, packs []domain.StickerPack) {
t.Helper()
ctx := context.Background()
var ids []int64
for _, p := range packs {
ids = append(ids, p.DocumentIDs...)
}
for _, id := range ids {
if err := media.PutDocument(ctx, domain.Document{ID: id, AccessHash: id, MimeType: "application/x-tgsticker"}); err != nil {
t.Fatalf("put document %d: %v", id, err)
}
}
if err := media.PutStickerSet(ctx, domain.StickerSet{
ID: 1,
ShortName: "AnimatedEmojies",
Kind: domain.StickerSetKindSystem,
SystemKey: "animated_emoji",
Animated: true,
DocumentIDs: ids,
Packs: packs,
}); err != nil {
t.Fatalf("put animated_emoji set: %v", err)
}
}
func TestEnsureDefaultEmojiStatusSet(t *testing.T) {
ctx := context.Background()
svc, media := defaultStatusesTestService(t)
putAnimatedEmojiSet(t, media, []domain.StickerPack{
// seed 导出常见为裸码点(无 FE0F),精选清单两种形态都必须匹配。
{Emoticon: "❤", DocumentIDs: []int64{101}},
{Emoticon: "👍", DocumentIDs: []int64{102}},
{Emoticon: "☕️", DocumentIDs: []int64{103}}, // 带 FE0F 的反向形态
{Emoticon: "🥔", DocumentIDs: []int64{999}}, // 不在精选清单,必须被排除
})
count, created, err := svc.EnsureDefaultEmojiStatusSet(ctx)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if !created || count != 3 {
t.Fatalf("ensure = (count=%d, created=%v), want (3, true)", count, created)
}
set, found, err := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses)
if err != nil || !found {
t.Fatalf("synthesized set not found: found=%v err=%v", found, err)
}
if set.Kind != domain.StickerSetKindSystem || !set.Emojis || set.Count != 3 || set.Hash == 0 {
t.Fatalf("set meta = %+v, want system kind, emojis, count 3, non-zero hash", set)
}
got := map[int64]bool{}
for _, id := range set.DocumentIDs {
got[id] = true
}
if !got[101] || !got[102] || !got[103] || got[999] {
t.Fatalf("document ids = %v, want 101/102/103 without 999", set.DocumentIDs)
}
// 精选顺序:☕(=103) 在 ❤(=101) 之前、❤ 在 👍(=102) 之前(按清单序而非 pack 序)。
index := map[int64]int{}
for i, id := range set.DocumentIDs {
index[id] = i
}
if !(index[103] < index[101] && index[101] < index[102]) {
t.Fatalf("document order = %v, want curated order ☕<❤<👍", set.DocumentIDs)
}
// 幂等:第二次调用不得重建。
count2, created2, err := svc.EnsureDefaultEmojiStatusSet(ctx)
if err != nil {
t.Fatalf("ensure again: %v", err)
}
if created2 || count2 != 3 {
t.Fatalf("ensure again = (count=%d, created=%v), want (3, false)", count2, created2)
}
// ResolveStickerSet(inputStickerSetEmojiDefaultStatuses 的服务路径)能解析。
resolved, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{
Kind: domain.StickerSetRefBySystem,
SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses,
})
if err != nil || !found {
t.Fatalf("resolve: found=%v err=%v", found, err)
}
if resolved.ID != set.ID || len(docs) != 3 {
t.Fatalf("resolve = set %d with %d docs, want %d with 3", resolved.ID, len(docs), set.ID)
}
}
func TestEnsureDefaultEmojiStatusSetWithoutSeed(t *testing.T) {
ctx := context.Background()
svc, media := defaultStatusesTestService(t)
count, created, err := svc.EnsureDefaultEmojiStatusSet(ctx)
if err != nil {
t.Fatalf("ensure without seed: %v", err)
}
if created || count != 0 {
t.Fatalf("ensure without seed = (count=%d, created=%v), want (0, false)", count, created)
}
if _, found, _ := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses); found {
t.Fatal("set must not be created without animated_emoji seed")
}
}

View file

@ -0,0 +1,167 @@
package files
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"hash"
"hash/fnv"
"os"
"path/filepath"
"telesrv/internal/domain"
)
// telegram_effects_export/effects.json 的解析结构。effects 引用文档 id,文档全量元数据
// 在 documents[] 里(与 messages.availableEffects 同构),blob 在 documents/<docid>.<ext>。
type seedEffectJSON struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id"`
PremiumRequired bool `json:"premium_required"`
}
type seedEffectsFileJSON struct {
Result struct {
Effects []seedEffectJSON `json:"effects"`
Documents []seedDocumentJSON `json:"documents"`
} `json:"result"`
}
// seedEffects 从 telegram_effects_export 导入消息特效。特效元数据每次启动都从 JSON
// 重建进内存;document/blob 只有 catalog hash 变化或持久化资源不完整时才重导。
func (s *Service) seedEffects(ctx context.Context, root string, stats *SeedStats) error {
dir := filepath.Join(root, "telegram_effects_export")
raw, err := os.ReadFile(filepath.Join(dir, "effects.json"))
if err != nil {
return nil // 无 effects 资源 → 跳过
}
var parsed seedEffectsFileJSON
if err := json.Unmarshal(raw, &parsed); err != nil {
return fmt.Errorf("parse effects.json: %w", err)
}
docsDir := filepath.Join(dir, "documents")
index, err := scanSeedDir(docsDir)
if err != nil {
return err
}
effects, requiredDocs := seedEffectsCatalog(parsed)
s.effects = effects
s.effectsHash = effectsCatalogHash(effects)
stats.Effects = len(effects)
stateHash, err := s.seedEffectsStateHash(raw, docsDir)
if err != nil {
return err
}
ready, err := s.seedDocumentJSONsReady(ctx, requiredDocs, index)
if err != nil {
return err
}
matched, err := s.seedStateMatches(ctx, seedEffectsStateKey, stateHash)
if err != nil {
return err
}
if matched && ready {
return nil
}
// 多个 effect 常共享同一文档(static icon 尤甚):每个唯一源文档只导一次。
for _, dj := range requiredDocs {
if _, err := s.importDocument(ctx, dj, docsDir, index, stats); err != nil {
return err
}
}
return s.putSeedState(ctx, seedEffectsStateKey, stateHash)
}
func seedEffectsCatalog(parsed seedEffectsFileJSON) ([]domain.AvailableEffect, []seedDocumentJSON) {
docByID := make(map[int64]seedDocumentJSON, len(parsed.Result.Documents))
for _, d := range parsed.Result.Documents {
docByID[d.ID] = d
}
required := make(map[int64]struct{}, len(docByID))
storageID := func(sourceID int64) int64 {
if sourceID == 0 {
return 0
}
if _, ok := docByID[sourceID]; !ok {
return 0
}
required[sourceID] = struct{}{}
return seedDocumentStorageID(sourceID)
}
effects := make([]domain.AvailableEffect, 0, len(parsed.Result.Effects))
for i, ej := range parsed.Result.Effects {
if ej.ID == 0 || ej.EffectStickerID == 0 {
continue
}
staticID := storageID(ej.StaticIconID)
stickerID := storageID(ej.EffectStickerID)
if stickerID == 0 {
continue
}
animID := storageID(ej.EffectAnimationID)
effects = append(effects, domain.AvailableEffect{
ID: ej.ID,
Emoticon: ej.Emoticon,
StaticIconID: staticID,
EffectStickerID: stickerID,
EffectAnimationID: animID,
PremiumRequired: ej.PremiumRequired,
Order: i,
})
}
docs := make([]seedDocumentJSON, 0, len(required))
for _, d := range parsed.Result.Documents {
if _, ok := required[d.ID]; ok {
docs = append(docs, d)
}
}
return effects, docs
}
func (s *Service) seedEffectsStateHash(raw []byte, docsDir string) (string, error) {
return seedStateHash(func(h hash.Hash) error {
writeSeedStateHeader(h, seedEffectsStateVersion, s.dc)
_, _ = h.Write(raw)
_, _ = h.Write([]byte{'\n'})
return writeSeedDirFingerprint(h, docsDir)
})
}
// AvailableEffects 返回 seed 进内存的消息特效目录与其内容 hash(全局静态;hash 在 seed 时
// 算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算)。
func (s *Service) AvailableEffects(ctx context.Context) ([]domain.AvailableEffect, int, error) {
return s.effects, s.effectsHash, nil
}
// effectsCatalogHash 由 effect 字段算稳定正整数 hash(FNV-1a)。内容变则 hash 变,
// 客户端发旧 hash 即不命中→重取。
func effectsCatalogHash(effects []domain.AvailableEffect) int {
if len(effects) == 0 {
return 0
}
h := fnv.New64a()
var buf [8]byte
put := func(v int64) {
binary.LittleEndian.PutUint64(buf[:], uint64(v))
_, _ = h.Write(buf[:])
}
for _, e := range effects {
put(e.ID)
_, _ = h.Write([]byte(e.Emoticon))
put(e.StaticIconID)
put(e.EffectStickerID)
put(e.EffectAnimationID)
if e.PremiumRequired {
put(1)
} else {
put(0)
}
}
return int(h.Sum64() & 0x7fffffff)
}

View file

@ -0,0 +1,50 @@
package files
import (
"context"
"fmt"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// CreateEncryptedFileFromUpload 把已上传分片组装成密聊文件 blob 并铸造 EncryptedFile 快照。
// 盲中继:内容是客户端加密的 bytes,不解析、不分类、不缩略图;blob 落 location_key
// "enc:<id>"(复用 BlobBackend,下载经 inputEncryptedFileLocation → 同 key)。
// access_hash 不强校验(沿用现有媒体 dev 姿态,依赖不可枚举 id)。元数据持久化由调用方
// (rpc 层经 SecretChats.PutEncryptedFile)负责。
func (s *Service) CreateEncryptedFileFromUpload(ctx context.Context, file domain.UploadedFileRef, keyFingerprint int) (domain.EncryptedFileRef, error) {
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.EncryptedFileRef{}, err
}
if body.Size == 0 {
return domain.EncryptedFileRef{}, domain.ErrDocumentInvalid
}
id := randomID()
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("enc:%d", id),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: body.ObjectKey,
Size: body.Size,
SHA256: body.SHA256,
MimeType: "application/octet-stream",
}); err != nil {
return domain.EncryptedFileRef{}, err
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup encrypted file upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("encrypted_file_id", id),
zap.Error(err))
}
return domain.EncryptedFileRef{
ID: id,
AccessHash: randomID(),
Size: body.Size,
DCID: s.dc,
KeyFingerprint: keyFingerprint,
}, nil
}

View file

@ -0,0 +1,223 @@
package files
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strings"
"sync"
"syscall"
"time"
"telesrv/internal/domain"
)
// 外链媒体:inputMediaPhotoExternal / inputMediaDocumentExternal——客户端给一个 URL,
// 服务端抓取并铸造 Photo/Document。抓取任意用户可控 URL,安全是核心:
// - SSRF 防护:自定义 Dialer.Control 在连接前检查**解析出的目标 IP**,挡掉 loopback/
// 私网/link-local/CGNAT/multicast/unspecified。因为每次实际 dial 都查,所以同时防住
// DNS rebinding(公网域名解析到内网 IP)与重定向(每一跳都重新 dial→重新检查)。
// - 仅 http/https;重定向上限;响应大小上限(LimitReader);请求超时;全局抓取限速
// (防一条消息触发大量服务端外网抓取的放大攻击)。
var (
// ErrExternalMediaDisabled 表示未启用外链媒体抓取(rpc 层映射为 MEDIA_INVALID)。
ErrExternalMediaDisabled = errors.New("external media disabled")
// ErrExternalMediaInvalid 表示 URL 不合法/被 SSRF 防护拦截/上游失败/超限。
ErrExternalMediaInvalid = errors.New("external media invalid")
)
const (
externalMediaTimeout = 15 * time.Second
externalMediaMaxRedirects = 5
// DefaultExternalMediaMaxBytes 是抓取响应体上限。
DefaultExternalMediaMaxBytes = int64(10 << 20)
// DefaultExternalMediaRatePerMin 是全局每分钟抓取上限(防放大攻击)。
DefaultExternalMediaRatePerMin = 60
externalMediaRateWindow = time.Minute
)
type externalMediaFetcher struct {
client *http.Client
maxBytes int64
rateLimit int
mu sync.Mutex
fetchTimes []time.Time
}
// WithExternalMedia 启用外链媒体抓取(inputMediaPhoto/DocumentExternal)。
// maxBytes<=0 用默认;ratePerMin<=0 用默认。SSRF 防护恒开。
func WithExternalMedia(maxBytes int64, ratePerMin int) Option {
return func(s *Service) {
if maxBytes <= 0 {
maxBytes = DefaultExternalMediaMaxBytes
}
if ratePerMin <= 0 {
ratePerMin = DefaultExternalMediaRatePerMin
}
s.externalMedia = newExternalMediaFetcher(maxBytes, ratePerMin, false)
}
}
// newExternalMediaFetcher 构造抓取器。allowPrivate 仅供测试(指向 httptest loopback);
// 生产恒 false。
func newExternalMediaFetcher(maxBytes int64, ratePerMin int, allowPrivate bool) *externalMediaFetcher {
dialer := &net.Dialer{Timeout: externalMediaTimeout}
dialer.Control = func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return ErrExternalMediaInvalid
}
ip := net.ParseIP(host)
if ip == nil {
return ErrExternalMediaInvalid
}
if !allowPrivate && isBlockedExternalIP(ip) {
return fmt.Errorf("%w: blocked address %s (SSRF guard)", ErrExternalMediaInvalid, host)
}
return nil
}
client := &http.Client{
Timeout: externalMediaTimeout,
Transport: &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= externalMediaMaxRedirects {
return fmt.Errorf("%w: too many redirects", ErrExternalMediaInvalid)
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("%w: blocked redirect scheme %q", ErrExternalMediaInvalid, req.URL.Scheme)
}
return nil
},
}
return &externalMediaFetcher{client: client, maxBytes: maxBytes, rateLimit: ratePerMin}
}
// isBlockedExternalIP 报告是否为不可对外抓取的内网/特殊地址(SSRF 防护)。
func isBlockedExternalIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
return true
}
// CGNAT 100.64.0.0/10(运营商级 NAT,常用于内部基础设施)。
if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
return true
}
return false
}
func (f *externalMediaFetcher) allowFetch() bool {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
kept := f.fetchTimes[:0]
for _, at := range f.fetchTimes {
if now.Sub(at) <= externalMediaRateWindow {
kept = append(kept, at)
}
}
f.fetchTimes = kept
if len(f.fetchTimes) >= f.rateLimit {
return false
}
f.fetchTimes = append(f.fetchTimes, now)
return true
}
// fetch 抓取 URL,返回 (字节, content-type)。SSRF 检查在 dial 阶段发生。
func (f *externalMediaFetcher) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, "", ErrExternalMediaInvalid
}
if !f.allowFetch() {
return nil, "", fmt.Errorf("%w: rate limited", ErrExternalMediaInvalid)
}
ctx, cancel := context.WithTimeout(ctx, externalMediaTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", ErrExternalMediaInvalid
}
req.Header.Set("User-Agent", "telesrv-media-fetch")
resp, err := f.client.Do(req)
if err != nil {
// 含 SSRF 拦截、超时、传输错误。
return nil, "", fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("%w: upstream status %d", ErrExternalMediaInvalid, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("%w: read body: %v", ErrExternalMediaInvalid, err)
}
if len(data) == 0 || int64(len(data)) > f.maxBytes {
return nil, "", fmt.Errorf("%w: body size %d", ErrExternalMediaInvalid, len(data))
}
contentType := resp.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
return data, strings.TrimSpace(contentType), nil
}
// CreatePhotoFromURL 抓取 URL 并铸造 Photo(CreatePhotoFromBytes 会解码校验是否为图片)。
func (s *Service) CreatePhotoFromURL(ctx context.Context, rawURL string) (domain.Photo, error) {
if s == nil || s.externalMedia == nil {
return domain.Photo{}, ErrExternalMediaDisabled
}
data, _, err := s.externalMedia.fetch(ctx, rawURL)
if err != nil {
return domain.Photo{}, err
}
photo, err := s.CreatePhotoFromBytes(ctx, data)
if err != nil {
// 非图片字节 → ErrPhotoInvalid,对外统一为 external invalid。
return domain.Photo{}, fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err)
}
return photo, nil
}
// CreateDocumentFromURL 抓取 URL 并铸造 Document:mime 取 Content-Type,文件名取 URL basename。
func (s *Service) CreateDocumentFromURL(ctx context.Context, rawURL string) (domain.Document, error) {
if s == nil || s.externalMedia == nil {
return domain.Document{}, ErrExternalMediaDisabled
}
data, contentType, err := s.externalMedia.fetch(ctx, rawURL)
if err != nil {
return domain.Document{}, err
}
mime := contentType
if mime == "" {
mime = "application/octet-stream"
}
spec := domain.DocumentSpec{
MimeType: mime,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: externalMediaFilename(rawURL)}},
}
doc, err := s.CreateDocumentFromBytes(ctx, data, spec)
if err != nil {
return domain.Document{}, fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err)
}
return doc, nil
}
// externalMediaFilename 从 URL path 取 basename;缺失时回退通用名。
func externalMediaFilename(rawURL string) string {
u, err := url.Parse(rawURL)
if err == nil {
if base := path.Base(u.Path); base != "" && base != "." && base != "/" {
return base
}
}
return "file"
}

View file

@ -0,0 +1,106 @@
package files
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestIsBlockedExternalIP(t *testing.T) {
cases := []struct {
ip string
blocked bool
}{
{"127.0.0.1", true}, // loopback
{"::1", true}, // loopback v6
{"10.0.0.5", true}, // private
{"172.16.3.4", true}, // private
{"192.168.1.1", true}, // private
{"169.254.1.1", true}, // link-local
{"fe80::1", true}, // link-local v6
{"0.0.0.0", true}, // unspecified
{"100.64.0.1", true}, // CGNAT
{"100.127.255.1", true}, // CGNAT 上界
{"224.0.0.1", true}, // multicast
{"8.8.8.8", false}, // 公网
{"1.1.1.1", false}, // 公网
{"100.63.255.1", false}, // CGNAT 下界外(公网)
{"100.128.0.1", false}, // CGNAT 上界外(公网)
{"2606:4700:4700::1111", false}, // 公网 v6
}
for _, c := range cases {
ip := net.ParseIP(c.ip)
if ip == nil {
t.Fatalf("parse %s failed", c.ip)
}
if got := isBlockedExternalIP(ip); got != c.blocked {
t.Errorf("isBlockedExternalIP(%s) = %v, want %v", c.ip, got, c.blocked)
}
}
}
// TestExternalMediaFetcherSSRFGuard 验证 SSRF 防护:httptest 在 loopback 上,
// allowPrivate=false 必须拦截(不连接内网),allowPrivate=true 放行抓取到字节。
func TestExternalMediaFetcherSSRFGuard(t *testing.T) {
body := []byte("hello-external-bytes")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(body)
}))
defer srv.Close()
// 生产配置(allowPrivate=false):loopback 目标被 SSRF 防护拦截。
guarded := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, false)
if _, _, err := guarded.fetch(context.Background(), srv.URL); !errors.Is(err, ErrExternalMediaInvalid) {
t.Fatalf("SSRF guard fetch err = %v, want ErrExternalMediaInvalid (loopback 应被拦)", err)
}
// 测试放行(allowPrivate=true):抓取成功。
open := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, true)
data, ct, err := open.fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("open fetch err = %v", err)
}
if string(data) != string(body) {
t.Fatalf("fetched %q, want %q", data, body)
}
if ct != "application/octet-stream" {
t.Fatalf("content-type = %q, want application/octet-stream", ct)
}
}
// TestExternalMediaFetcherRejectsBadURL 非 http(s)/空 host 直接拒。
func TestExternalMediaFetcherRejectsBadURL(t *testing.T) {
f := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, true)
for _, bad := range []string{"", "ftp://x/y", "file:///etc/passwd", "javascript:alert(1)", "http://", "not a url"} {
if _, _, err := f.fetch(context.Background(), bad); !errors.Is(err, ErrExternalMediaInvalid) {
t.Errorf("fetch(%q) err = %v, want ErrExternalMediaInvalid", bad, err)
}
}
}
// TestExternalMediaFetcherSizeLimit 超大小上限拒。
func TestExternalMediaFetcherSizeLimit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(make([]byte, 2048))
}))
defer srv.Close()
f := newExternalMediaFetcher(1024, DefaultExternalMediaRatePerMin, true)
if _, _, err := f.fetch(context.Background(), srv.URL); !errors.Is(err, ErrExternalMediaInvalid) {
t.Fatalf("oversize fetch err = %v, want ErrExternalMediaInvalid", err)
}
}
// TestExternalMediaDisabled 未启用时 Create*FromURL 返回 ErrExternalMediaDisabled。
func TestExternalMediaDisabled(t *testing.T) {
s := &Service{}
if _, err := s.CreatePhotoFromURL(context.Background(), "http://x/y.png"); !errors.Is(err, ErrExternalMediaDisabled) {
t.Fatalf("disabled photo err = %v, want ErrExternalMediaDisabled", err)
}
if _, err := s.CreateDocumentFromURL(context.Background(), "http://x/y.bin"); !errors.Is(err, ErrExternalMediaDisabled) {
t.Fatalf("disabled doc err = %v, want ErrExternalMediaDisabled", err)
}
}

View file

@ -0,0 +1,183 @@
package files
import (
"bytes"
"image"
"image/color"
"image/png"
"math"
"go.uber.org/zap"
)
// 本文件实现 geo 消息地图缩略图(upload.getWebFile / inputWebFileGeoPointLocation)的
// 本地占位渲染:服务端按坐标确定性合成一张「街区网格 + 定位针」风格的静态图,
// 同一 (lat,long,zoom,w,h,scale) 输入字节级可重现,保证客户端分片续传一致。
// 配置了 Mapbox 代理(maptile_proxy.go)时优先返回真实地图,本渲染降级为故障回退。
const (
mapTileMinEdge = 16
mapTileMaxEdge = 1024
mapTileMinZoom = 13
mapTileMaxZoom = 20
mapTileMaxScale = 3
)
// GeoMapTile 返回一张 w×h(逻辑像素,输出按 scale 放大)的静态地图与 mime。
// 入参越界时按协议约束 clamp(w/h 16-1024、zoom 13-20、scale 1-3),不报错。
// 配置了 Mapbox 代理时优先真实地图(落盘缓存),抓取失败回退确定性占位渲染。
func (s *Service) GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) {
w = clampInt(w, mapTileMinEdge, mapTileMaxEdge)
h = clampInt(h, mapTileMinEdge, mapTileMaxEdge)
zoom = clampInt(zoom, mapTileMinZoom, mapTileMaxZoom)
scale = clampInt(scale, 1, mapTileMaxScale)
// nil receiver 合法:占位渲染是纯函数(既有调用方/测试依赖这一点),代理仅在配置后启用。
if s != nil && s.mapTiles != nil {
if data, mime, err := s.mapTiles.tile(lat, long, w, h, zoom, scale); err == nil {
return data, mime
} else if s.log != nil {
s.log.Warn("map tile proxy failed, fallback to placeholder",
zap.Error(err), zap.Float64("lat", lat), zap.Float64("long", long), zap.Int("zoom", zoom))
}
}
pw, ph := w*scale, h*scale
img := image.NewRGBA(image.Rect(0, 0, pw, ph))
background := color.RGBA{R: 0xEB, G: 0xE7, B: 0xDE, A: 0xFF}
park := color.RGBA{R: 0xCF, G: 0xE4, B: 0xC2, A: 0xFF}
road := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
roadEdge := color.RGBA{R: 0xD9, G: 0xD3, B: 0xC7, A: 0xFF}
fillRect(img, 0, 0, pw, ph, background)
rng := newMapTileRNG(lat, long, zoom)
// 街区网格:间距与抖动由坐标种子决定,平移随经纬度连续变化,避免所有地点一张脸。
spacing := (48 + int(rng.next()%32)) * scale
offX := int(math.Abs(long*1e4)) % spacing
offY := int(math.Abs(lat*1e4)) % spacing
for x := -offX; x < pw; x += spacing {
major := ((x+offX)/spacing)%3 == int(rng.next()%3)
drawVerticalRoad(img, x+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge)
}
for y := -offY; y < ph; y += spacing {
major := ((y+offY)/spacing)%3 == int(rng.next()%3)
drawHorizontalRoad(img, y+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge)
}
// 两块「绿地」:取网格内随机街块,铺底色之上、道路之下的视觉层级太复杂,
// 这里直接半覆盖即可(占位图不追求制图精度)。
for i := 0; i < 2; i++ {
bx := int(rng.next() % uint64(pw))
by := int(rng.next() % uint64(ph))
bw := (spacing * 3) / 4
fillRect(img, bx, by, minInt(bx+bw, pw), minInt(by+bw, ph), park)
}
drawCenterPin(img, pw, ph, scale)
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes(), "image/png"
}
// mapTileRNG 是确定性 xorshift64,种子来自量化坐标与 zoom。
type mapTileRNG struct{ state uint64 }
func newMapTileRNG(lat, long float64, zoom int) *mapTileRNG {
seed := uint64(int64(lat*1e5))*1000003 ^ uint64(int64(long*1e5))*998244353 ^ uint64(zoom)*0x9E3779B97F4A7C15
if seed == 0 {
seed = 0x9E3779B97F4A7C15
}
return &mapTileRNG{state: seed}
}
func (r *mapTileRNG) next() uint64 {
r.state ^= r.state << 13
r.state ^= r.state >> 7
r.state ^= r.state << 17
return r.state
}
func fillRect(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
bounds := img.Bounds()
x0, y0 = maxInt(x0, bounds.Min.X), maxInt(y0, bounds.Min.Y)
x1, y1 = minInt(x1, bounds.Max.X), minInt(y1, bounds.Max.Y)
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
img.SetRGBA(x, y, c)
}
}
}
func drawVerticalRoad(img *image.RGBA, x, pw, ph, scale int, major bool, road, edge color.RGBA) {
width := 2 * scale
if major {
width = 4 * scale
}
fillRect(img, x-width/2-1, 0, x+width/2+1, ph, edge)
fillRect(img, x-width/2, 0, x+width/2, ph, road)
}
func drawHorizontalRoad(img *image.RGBA, y, pw, ph, scale int, major bool, road, edge color.RGBA) {
width := 2 * scale
if major {
width = 4 * scale
}
fillRect(img, 0, y-width/2-1, pw, y+width/2+1, edge)
fillRect(img, 0, y-width/2, pw, y+width/2, road)
}
// drawCenterPin 在图中心画红色定位针(圆头 + 下尖三角 + 白色内点),中心即坐标点。
func drawCenterPin(img *image.RGBA, pw, ph, scale int) {
pin := color.RGBA{R: 0xE5, G: 0x39, B: 0x35, A: 0xFF}
pinDark := color.RGBA{R: 0xB7, G: 0x1C, B: 0x1C, A: 0xFF}
white := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
cx, cy := pw/2, ph/2
headR := 9 * scale
headCY := cy - 14*scale
// 尖角三角形:从圆头两侧收敛到坐标点。
for y := headCY; y <= cy; y++ {
t := float64(y-headCY) / float64(cy-headCY)
half := int(float64(headR) * (1 - t) * 0.82)
for x := cx - half; x <= cx+half; x++ {
img.SetRGBA(x, y, pin)
}
}
// 圆头(带一圈深色描边)。
for dy := -headR - scale; dy <= headR+scale; dy++ {
for dx := -headR - scale; dx <= headR+scale; dx++ {
d2 := dx*dx + dy*dy
switch {
case d2 <= headR*headR:
img.SetRGBA(cx+dx, headCY+dy, pin)
case d2 <= (headR+scale)*(headR+scale):
img.SetRGBA(cx+dx, headCY+dy, pinDark)
}
}
}
innerR := 3 * scale
for dy := -innerR; dy <= innerR; dy++ {
for dx := -innerR; dx <= innerR; dx++ {
if dx*dx+dy*dy <= innerR*innerR {
img.SetRGBA(cx+dx, headCY+dy, white)
}
}
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,404 @@
package files
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
// 本文件实现地图缩略图的真实数据源:upload.getWebFile 命中 geo 坐标时,服务端代理
// Mapbox Static Images API 抓取一张静态地图并落盘缓存。客户端按 offset/limit 分片下载
// 同一文件,字节必须全程一致,因此:
// - 抓取成功先原子落盘(temp+rename),所有分片一律从缓存文件读;
// - 落盘失败时字节进短 TTL 进程内缓存兜底——分片是顺序请求、singleflight 合并不了,
// 没有这层缓存会退化成每分片一次外网抓取,且两次抓取的字节无逐字节一致保证;
// - 抓取失败记一个短 TTL 负缓存,期间该 key 直接走确定性占位图,避免同一次下载
// 前后分片在「真图/占位图」之间翻转,也避免上游故障时每个分片都打一次外网。
//
// 资源边界(key 空间由客户端可控的坐标×尺寸组合构成,必须设防):
// - 抓取尺寸量化到 32px 档位,收窄 key 空间与缓存基数;
// - 磁盘缓存有总量上限,超限按 mtime 从旧到新淘汰(顺带回收崩溃遗留 .tmp);
// - 上游抓取有全局速率上限,超限当次退占位图,防止恶意枚举烧 Mapbox 配额。
//
// 地图不内嵌定位针:TDesktop(historyMapPoint icon)与 DrKLO 都在客户端叠加 marker,
// 与官方静态图行为一致。
const (
mapTileFetchTimeout = 15 * time.Second
mapTileMaxFetchBytes = 8 << 20 // 防御性上限;640x640@2x PNG 远小于此
mapTileFailureTTL = time.Minute
mapboxStaticStyleBase = "/styles/v1/mapbox/streets-v12/static"
// mapTileEdgeStep 是抓取尺寸的量化步长(向上取整);客户端拿到略大的图自适应缩放。
mapTileEdgeStep = 32
// mapTileMemTTL/mapTileMemMaxBytes 是落盘失败兜底字节缓存的保留期与总量上限。
mapTileMemTTL = 10 * time.Minute
mapTileMemMaxBytes = 32 << 20
// mapTileDiskMaxBytes 是磁盘缓存总量上限;超限按 mtime 淘汰到 90%。
mapTileDiskMaxBytes = int64(256 << 20)
// mapTileFetchRateLimit 是全局每分钟上游抓取上限(防恶意坐标枚举烧配额)。
mapTileFetchRateLimit = 120
mapTileFetchRateWindow = time.Minute
// mapTileTmpMaxAge 是崩溃遗留 .tmp 的回收阈值。
mapTileTmpMaxAge = time.Hour
)
type memTileEntry struct {
data []byte
at time.Time
}
type mapTileProxy struct {
token string
baseURL string // 默认 https://api.mapbox.com;测试注入 httptest 地址
dir string
client *http.Client
log *zap.Logger
maxDiskBytes int64
group singleflight.Group
sweepMu sync.Mutex
mu sync.Mutex
failures map[string]time.Time // key → 失败时刻(负缓存)
memTiles map[string]memTileEntry
memBytes int
fetchTimes []time.Time // 上游抓取滑动窗口
}
// WithMapboxMapTiles 启用 Mapbox 静态地图代理;token 为空时不启用(保持占位图)。
// logger 由 NewService 在全部 Option 应用后统一注入。
func WithMapboxMapTiles(token, cacheDir string) Option {
return func(s *Service) {
if token == "" || cacheDir == "" {
return
}
s.mapTiles = &mapTileProxy{
token: token,
baseURL: "https://api.mapbox.com",
dir: cacheDir,
client: &http.Client{Timeout: mapTileFetchTimeout},
maxDiskBytes: mapTileDiskMaxBytes,
failures: make(map[string]time.Time),
memTiles: make(map[string]memTileEntry),
}
}
}
// tile 返回 (lat,long,zoom,w,h,scale) 对应的静态地图字节;入参须已 clamp。
func (p *mapTileProxy) tile(lat, long float64, w, h, zoom, scale int) ([]byte, string, error) {
// Mapbox 静态图只支持 @2x;scale 3 同样按 2x 抓(客户端按目标尺寸自适应缩放)。
retina := ""
if scale >= 2 {
retina = "@2x"
}
// 尺寸量化收窄客户端可铸造的 key 空间;客户端按目标矩形自适应缩放略大的图。
w = quantizeTileEdge(w)
h = quantizeTileEdge(h)
key := fmt.Sprintf("v1-%.5f-%.5f-%d-%dx%d%s", lat, long, zoom, w, h, retina)
path := p.cachePath(key)
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
return data, mapTileMime(data), nil
}
if data, ok := p.cachedMemTile(key); ok {
return data, mapTileMime(data), nil
}
if p.recentlyFailed(key) {
return nil, "", errors.New("map tile fetch in failure backoff")
}
v, err, _ := p.group.Do(key, func() (any, error) {
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
return data, nil
}
if data, ok := p.cachedMemTile(key); ok {
return data, nil
}
if !p.allowFetch() {
// 全局抓取限速:负缓存让该 key 短期稳定走占位图(保持分片字节一致),不打上游。
p.markFailed(key)
return nil, errors.New("map tile fetch rate limited")
}
data, err := p.fetch(lat, long, w, h, zoom, retina)
if err != nil {
p.markFailed(key)
return nil, err
}
if err := p.store(path, data); err != nil {
// 分片是顺序请求,singleflight 合并不了后续分片;字节必须进内存缓存兜底,
// 否则磁盘持续故障会退化成每分片一次外网抓取且字节无一致性保证。
p.rememberMemTile(key, data)
p.log.Warn("map tile cache write failed, serving from memory", zap.Error(err), zap.String("key", key))
} else {
p.sweepDisk()
}
return data, nil
})
if err != nil {
return nil, "", err
}
data := v.([]byte)
return data, mapTileMime(data), nil
}
// quantizeTileEdge 把边长向上量化到 mapTileEdgeStep 的整数倍(caller 已 clamp 到 16..1024)。
func quantizeTileEdge(v int) int {
q := ((v + mapTileEdgeStep - 1) / mapTileEdgeStep) * mapTileEdgeStep
if q > mapTileMaxEdge {
return mapTileMaxEdge
}
return q
}
// fetch 请求 Mapbox Static Images API。注意 URL 坐标顺序是 {long},{lat}。
func (p *mapTileProxy) fetch(lat, long float64, w, h, zoom int, retina string) ([]byte, error) {
endpoint := fmt.Sprintf("%s%s/%.5f,%.5f,%d/%dx%d%s?access_token=%s&attribution=false&logo=false",
p.baseURL, mapboxStaticStyleBase, long, lat, zoom, w, h, retina, url.QueryEscape(p.token))
// 不透传 RPC ctx:singleflight 结果被并发分片共享,单个调用方取消不应拖垮整次抓取。
ctx, cancel := context.WithTimeout(context.Background(), mapTileFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build map tile request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
// 传输层错误内嵌完整 URL(含 access_token),脱敏后再向上传播/落日志。
return nil, fmt.Errorf("fetch map tile: %s", p.redactToken(err.Error()))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch map tile: upstream status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, mapTileMaxFetchBytes+1))
if err != nil {
return nil, fmt.Errorf("read map tile body: %w", err)
}
if len(data) == 0 || len(data) > mapTileMaxFetchBytes {
return nil, fmt.Errorf("map tile body size invalid: %d", len(data))
}
if mime := mapTileMime(data); mime == "" {
return nil, errors.New("map tile body is not an image")
}
return data, nil
}
func (p *mapTileProxy) cachePath(key string) string {
sum := sha256.Sum256([]byte(key))
return filepath.Join(p.dir, hex.EncodeToString(sum[:])+".img")
}
func (p *mapTileProxy) store(path string, data []byte) error {
if err := os.MkdirAll(p.dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(p.dir, "tile-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
// redactToken 把错误文本中的 access token(原样或 URL 转义形态)替换为占位符。
func (p *mapTileProxy) redactToken(s string) string {
if p.token == "" {
return s
}
s = strings.ReplaceAll(s, p.token, "***")
if escaped := url.QueryEscape(p.token); escaped != p.token {
s = strings.ReplaceAll(s, escaped, "***")
}
return s
}
// cachedMemTile 返回落盘失败兜底缓存中的字节(TTL 内)。
func (p *mapTileProxy) cachedMemTile(key string) ([]byte, bool) {
p.mu.Lock()
defer p.mu.Unlock()
entry, ok := p.memTiles[key]
if !ok {
return nil, false
}
if time.Since(entry.at) > mapTileMemTTL {
p.memBytes -= len(entry.data)
delete(p.memTiles, key)
return nil, false
}
return entry.data, true
}
// rememberMemTile 在落盘失败时暂存字节;超总量按最旧淘汰。
func (p *mapTileProxy) rememberMemTile(key string, data []byte) {
if len(data) > mapTileMemMaxBytes {
return
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for k, entry := range p.memTiles {
if now.Sub(entry.at) > mapTileMemTTL {
p.memBytes -= len(entry.data)
delete(p.memTiles, k)
}
}
if old, ok := p.memTiles[key]; ok {
p.memBytes -= len(old.data)
}
for p.memBytes+len(data) > mapTileMemMaxBytes && len(p.memTiles) > 0 {
oldestKey := ""
var oldestAt time.Time
for k, entry := range p.memTiles {
if oldestKey == "" || entry.at.Before(oldestAt) {
oldestKey, oldestAt = k, entry.at
}
}
p.memBytes -= len(p.memTiles[oldestKey].data)
delete(p.memTiles, oldestKey)
}
p.memTiles[key] = memTileEntry{data: data, at: now}
p.memBytes += len(data)
}
// allowFetch 是全局上游抓取限速(滑动窗口);超限返回 false。
func (p *mapTileProxy) allowFetch() bool {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
kept := p.fetchTimes[:0]
for _, at := range p.fetchTimes {
if now.Sub(at) <= mapTileFetchRateWindow {
kept = append(kept, at)
}
}
p.fetchTimes = kept
if len(p.fetchTimes) >= mapTileFetchRateLimit {
return false
}
p.fetchTimes = append(p.fetchTimes, now)
return true
}
// sweepDisk 在新写入后核算缓存目录总量,超限按 mtime 从旧到新淘汰到 90%,
// 顺带回收崩溃遗留的过期 .tmp。store 仅发生在上游抓取后(低频),同步扫描可接受。
func (p *mapTileProxy) sweepDisk() {
p.sweepMu.Lock()
defer p.sweepMu.Unlock()
entries, err := os.ReadDir(p.dir)
if err != nil {
return
}
type tileFile struct {
name string
size int64
mod time.Time
}
var files []tileFile
var total int64
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if strings.HasSuffix(entry.Name(), ".tmp") {
if now.Sub(info.ModTime()) > mapTileTmpMaxAge {
_ = os.Remove(filepath.Join(p.dir, entry.Name()))
}
continue
}
if !strings.HasSuffix(entry.Name(), ".img") {
continue
}
files = append(files, tileFile{name: entry.Name(), size: info.Size(), mod: info.ModTime()})
total += info.Size()
}
if total <= p.maxDiskBytes {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) })
target := p.maxDiskBytes * 9 / 10
removed := 0
for _, f := range files {
if total <= target {
break
}
if err := os.Remove(filepath.Join(p.dir, f.name)); err == nil {
total -= f.size
removed++
}
}
if removed > 0 && p.log != nil {
p.log.Info("map tile cache swept", zap.Int("removed", removed), zap.Int64("remaining_bytes", total))
}
}
func (p *mapTileProxy) recentlyFailed(key string) bool {
p.mu.Lock()
defer p.mu.Unlock()
at, ok := p.failures[key]
if !ok {
return false
}
if time.Since(at) > mapTileFailureTTL {
delete(p.failures, key)
return false
}
return true
}
func (p *mapTileProxy) markFailed(key string) {
p.mu.Lock()
defer p.mu.Unlock()
// 顺手清理过期项,防 map 无界增长(key 空间本身有限:坐标×尺寸枚举)。
now := time.Now()
for k, at := range p.failures {
if now.Sub(at) > mapTileFailureTTL {
delete(p.failures, k)
}
}
p.failures[key] = now
}
// mapTileMime 按魔数识别图片类型;非图片返回空串。
func mapTileMime(data []byte) string {
switch {
case len(data) > 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
return "image/png"
case len(data) > 3 && data[0] == 0xFF && data[1] == 0xD8:
return "image/jpeg"
case len(data) > 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
return "image/webp"
default:
return ""
}
}

View file

@ -0,0 +1,243 @@
package files
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakePNG 是最小合法 PNG 头 + 填充(只需通过魔数识别,不需要可解码)。
var fakePNG = append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, bytes.Repeat([]byte{0x42}, 64)...)
func newProxyTestService(t *testing.T, handler http.Handler) (*Service, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
s := NewService(nil, nil, 2, WithMapboxMapTiles("test-token", t.TempDir()))
if s.mapTiles == nil {
t.Fatal("map tile proxy not configured")
}
s.mapTiles.baseURL = srv.URL
return s, srv
}
func TestGeoMapTileProxyFetchesAndCaches(t *testing.T) {
var calls atomic.Int32
var lastPath, lastQuery string
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
lastPath = r.URL.Path
lastQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
first, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if mime != "image/png" {
t.Fatalf("mime = %q, want image/png", mime)
}
if !bytes.Equal(first, fakePNG) {
t.Fatal("first fetch did not return upstream bytes")
}
// Mapbox 形态:/styles/v1/mapbox/streets-v12/static/{long},{lat},{zoom}/{w}x{h}@2x
if !strings.Contains(lastPath, "/static/116.40740,39.90420,15/256x128@2x") {
t.Fatalf("unexpected upstream path: %s", lastPath)
}
if !strings.Contains(lastQuery, "access_token=test-token") {
t.Fatalf("missing access token in query: %s", lastQuery)
}
// 第二次(含分片重复读)必须走磁盘缓存,不再触发上游请求。
second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if !bytes.Equal(first, second) {
t.Fatal("cached bytes differ from first fetch")
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (second hit must be cached)", got)
}
}
func TestGeoMapTileProxyScaleOneOmitsRetina(t *testing.T) {
var lastPath string
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastPath = r.URL.Path
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
s.GeoMapTile(1.5, 2.5, 100, 100, 16, 1)
if strings.Contains(lastPath, "@2x") {
t.Fatalf("scale=1 must not request retina: %s", lastPath)
}
}
func TestGeoMapTileProxyFallsBackToPlaceholder(t *testing.T) {
var calls atomic.Int32
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, "boom", http.StatusInternalServerError)
}))
data, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if len(data) == 0 || mime != "image/png" {
t.Fatalf("fallback placeholder missing: len=%d mime=%q", len(data), mime)
}
// 占位图确定性:回退路径必须与纯占位服务字节一致(分片续传一致性)。
plain := NewService(nil, nil, 2)
expected, _ := plain.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if !bytes.Equal(data, expected) {
t.Fatal("fallback placeholder differs from deterministic rendering")
}
// 负缓存:失败后的后续分片请求不应继续打上游。
again, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if !bytes.Equal(again, expected) {
t.Fatal("placeholder must stay byte-identical during failure backoff")
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (failure must be negative-cached)", got)
}
// 负缓存过期后允许重试并恢复真实地图。
s.mapTiles.mu.Lock()
for k := range s.mapTiles.failures {
s.mapTiles.failures[k] = time.Now().Add(-2 * mapTileFailureTTL)
}
s.mapTiles.mu.Unlock()
// 上游恢复。
s.mapTiles.baseURL = newRecoveredUpstream(t)
recovered, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if !bytes.Equal(recovered, fakePNG) {
t.Fatal("proxy must recover after failure TTL expires")
}
}
func newRecoveredUpstream(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
t.Cleanup(srv.Close)
return srv.URL
}
// 落盘失败时字节必须进内存兜底缓存:顺序分片不再逐片打上游,且字节全程一致。
func TestGeoMapTileProxyStoreFailureServesFromMemory(t *testing.T) {
var calls atomic.Int32
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
// 让缓存目录路径指向一个普通文件 → MkdirAll/写盘必然失败。
blocked := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s.mapTiles.dir = blocked
first, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if !bytes.Equal(first, fakePNG) || !bytes.Equal(second, fakePNG) {
t.Fatal("store-failure path must keep serving upstream bytes")
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (memory cache must absorb后续分片)", got)
}
}
// 抓取尺寸量化到 32px 档位,收窄客户端可铸造的缓存 key 空间。
func TestGeoMapTileProxyQuantizesFetchDimensions(t *testing.T) {
var mu sync.Mutex
var lastPath string
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
lastPath = r.URL.Path
mu.Unlock()
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
s.GeoMapTile(1.5, 2.5, 100, 50, 15, 2)
mu.Lock()
defer mu.Unlock()
if !strings.Contains(lastPath, "/128x64@2x") {
t.Fatalf("dimensions not quantized to 32px steps: %s", lastPath)
}
}
// 磁盘缓存超总量上限后按 mtime 从旧到新淘汰。
func TestGeoMapTileProxyDiskSweepEvictsOldest(t *testing.T) {
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
s.mapTiles.maxDiskBytes = int64(len(fakePNG)*2 + 8) // 容得下 2 张,第 3 张触发淘汰
for i := 0; i < 3; i++ {
s.GeoMapTile(10+float64(i), 20, 128, 128, 15, 1)
time.Sleep(20 * time.Millisecond) // 保证 mtime 可区分
}
entries, err := os.ReadDir(s.mapTiles.dir)
if err != nil {
t.Fatal(err)
}
var total int64
count := 0
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
t.Fatal(err)
}
total += info.Size()
count++
}
if count >= 3 || total > s.mapTiles.maxDiskBytes {
t.Fatalf("sweep did not evict: files=%d total=%d max=%d", count, total, s.mapTiles.maxDiskBytes)
}
}
// 全局抓取限速:超限的新 key 退占位图且不打上游。
func TestGeoMapTileProxyFetchRateLimit(t *testing.T) {
var calls atomic.Int32
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNG)
}))
// 预填满滑动窗口。
s.mapTiles.mu.Lock()
now := time.Now()
for i := 0; i < mapTileFetchRateLimit; i++ {
s.mapTiles.fetchTimes = append(s.mapTiles.fetchTimes, now)
}
s.mapTiles.mu.Unlock()
data, mime := s.GeoMapTile(33.3, 44.4, 128, 128, 15, 1)
plain := NewService(nil, nil, 2)
expected, _ := plain.GeoMapTile(33.3, 44.4, 128, 128, 15, 1)
if !bytes.Equal(data, expected) || mime != "image/png" {
t.Fatal("rate-limited request must fall back to deterministic placeholder")
}
if got := calls.Load(); got != 0 {
t.Fatalf("upstream calls = %d, want 0 when rate limited", got)
}
}
func TestGeoMapTileProxyRejectsNonImageBody(t *testing.T) {
s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html>not a map</html>"))
}))
data, mime := s.GeoMapTile(10, 20, 128, 128, 15, 1)
plain := NewService(nil, nil, 2)
expected, _ := plain.GeoMapTile(10, 20, 128, 128, 15, 1)
if !bytes.Equal(data, expected) || mime != "image/png" {
t.Fatal("non-image upstream body must fall back to placeholder")
}
}

View file

@ -0,0 +1,48 @@
package files
import (
"bytes"
"image/png"
"testing"
)
func TestGeoMapTileDeterministicPNG(t *testing.T) {
var s *Service
first, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2)
if mime != "image/png" {
t.Fatalf("mime = %q, want image/png", mime)
}
if !bytes.Equal(first, second) {
t.Fatal("map tile must be byte-identical for identical input (chunked download consistency)")
}
img, err := png.Decode(bytes.NewReader(first))
if err != nil {
t.Fatalf("decode png: %v", err)
}
if img.Bounds().Dx() != 512 || img.Bounds().Dy() != 256 {
t.Fatalf("tile dims = %dx%d, want 512x256 (w*scale x h*scale)", img.Bounds().Dx(), img.Bounds().Dy())
}
}
func TestGeoMapTileClampsBounds(t *testing.T) {
var s *Service
tile, _ := s.GeoMapTile(0, 0, 99999, -5, 99, 9)
img, err := png.Decode(bytes.NewReader(tile))
if err != nil {
t.Fatalf("decode png: %v", err)
}
// w clamp 1024、h clamp 16、scale clamp 3。
if img.Bounds().Dx() != 1024*3 || img.Bounds().Dy() != 16*3 {
t.Fatalf("tile dims = %dx%d, want 3072x48", img.Bounds().Dx(), img.Bounds().Dy())
}
}
func TestGeoMapTileDiffersByLocation(t *testing.T) {
var s *Service
a, _ := s.GeoMapTile(39.9042, 116.4074, 128, 128, 15, 1)
b, _ := s.GeoMapTile(31.2304, 121.4737, 128, 128, 15, 1)
if bytes.Equal(a, b) {
t.Fatal("different locations should render different tiles")
}
}

View file

@ -0,0 +1,243 @@
package files
import "encoding/binary"
// faststartMP4 把 MP4 的 moov 原子移到 mdat 之前(faststart),并把 moov 内所有
// stco/co64 chunk 偏移整体加上 moov 的大小(因为 moov 插到 mdat 前会把媒体数据整体后移)。
// 返回 (newData, changed)。非 MP4 / 已 faststart / 任何解析异常时返回 (data, false) 原样,
// 绝不破坏数据——这是上传热路径,宁可不优化也不能把好视频改坏。
//
// 背景:TDesktop 的 story 流式播放路径无法处理 moov 在文件末尾的视频(av_read_frame
// 报 Invalid data),普通 Telegram 客户端上传前会 faststart。telesrv 在落盘前补这一步,
// 让 supports_streaming=true 的承诺对所有客户端成立。不转码、保留原编码(含 HEVC)。
func faststartMP4(data []byte) ([]byte, bool) {
boxes, ok := parseTopLevelBoxes(data)
if !ok {
return data, false
}
ftypIdx, moovIdx, firstMdatIdx := -1, -1, -1
for i, b := range boxes {
switch b.typ {
case "ftyp":
if ftypIdx < 0 {
ftypIdx = i
}
case "moov":
if moovIdx < 0 {
moovIdx = i
}
case "mdat":
if firstMdatIdx < 0 {
firstMdatIdx = i
}
}
}
// 必须有 ftyp(且在最前)、moov、mdat;moov 已在 mdat 前则已 faststart。
if ftypIdx != 0 || moovIdx < 0 || firstMdatIdx < 0 {
return data, false
}
if moovIdx < firstMdatIdx {
return data, false
}
// 拷出 moov(独立底层数组,后续就地改偏移不影响原 data)。
moov := append([]byte(nil), data[boxes[moovIdx].start:boxes[moovIdx].end]...)
moovSize := int64(len(moov))
if !patchChunkOffsets(moov, moovSize) {
return data, false
}
// 重组:ftyp + moov + 其余 box(除 ftyp/moov)按原顺序。
out := make([]byte, 0, len(data))
out = append(out, data[boxes[ftypIdx].start:boxes[ftypIdx].end]...)
out = append(out, moov...)
for i, b := range boxes {
if i == ftypIdx || i == moovIdx {
continue
}
out = append(out, data[b.start:b.end]...)
}
if len(out) != len(data) {
// 长度必须守恒(只搬不改大小);不守恒说明哪里算错,保守放弃。
return data, false
}
return out, true
}
// mp4Layout 是只读 box 头得出的顶层结构信息,用于在不读取整段媒体的前提下判断是否需要
// faststart 以及如何流式重写。
type mp4Layout struct {
needsFaststart bool // moov 在 mdat 之后
moovIsLast bool // moov 是最后一个顶层 box(可走流式重写)
ftypStart, ftypEnd int64
moovStart, moovEnd int64
}
// inspectMP4Layout 只读顶层 box 头(每个 ≤16 字节,跳过 box 负载)来判定结构,避免为了
// 「检查是否已 faststart」而把整段视频读进内存。readAt(off, n) 读取 [off, off+n) 字节。
// 非 MP4 / 结构异常返回 (·, false)。
func inspectMP4Layout(size int64, readAt func(off, n int64) ([]byte, error)) (mp4Layout, bool) {
l := mp4Layout{moovStart: -1, moovEnd: -1}
mdatStart := int64(-1)
ftypSeen := false
p := int64(0)
for boxes := 0; p+8 <= size; boxes++ {
if boxes > 1024 { // 顶层 box 数量上限,防御异常文件
return mp4Layout{}, false
}
hdr, err := readAt(p, 16)
if err != nil || int64(len(hdr)) < 8 {
return mp4Layout{}, false
}
boxSize := int64(binary.BigEndian.Uint32(hdr[0:4]))
typ := string(hdr[4:8])
switch {
case boxSize == 1:
if len(hdr) < 16 {
return mp4Layout{}, false
}
boxSize = int64(binary.BigEndian.Uint64(hdr[8:16]))
case boxSize == 0:
boxSize = size - p
}
if boxSize < 8 || p+boxSize > size {
return mp4Layout{}, false
}
switch typ {
case "ftyp":
if p != 0 {
return mp4Layout{}, false // ftyp 必须在最前
}
ftypSeen = true
l.ftypStart, l.ftypEnd = p, p+boxSize
case "moov":
if l.moovStart < 0 {
l.moovStart, l.moovEnd = p, p+boxSize
}
case "mdat":
if mdatStart < 0 {
mdatStart = p
}
}
p += boxSize
}
if p != size || !ftypSeen || l.moovStart < 0 || mdatStart < 0 {
return mp4Layout{}, false
}
l.needsFaststart = l.moovStart > mdatStart
l.moovIsLast = l.moovEnd == size
return l, true
}
type boxRef struct {
typ string
start, end int
}
// parseTopLevelBoxes 顺序解析顶层 box,要求恰好无缝覆盖整个 data,否则视为非法不处理。
func parseTopLevelBoxes(data []byte) ([]boxRef, bool) {
var boxes []boxRef
p := 0
for p+8 <= len(data) {
size := int(binary.BigEndian.Uint32(data[p : p+4]))
typ := string(data[p+4 : p+8])
switch {
case size == 1:
if p+16 > len(data) {
return nil, false
}
size64 := binary.BigEndian.Uint64(data[p+8 : p+16])
size = int(size64)
case size == 0:
size = len(data) - p
}
if size < 8 || p+size > len(data) {
return nil, false
}
boxes = append(boxes, boxRef{typ: typ, start: p, end: p + size})
p += size
}
if p != len(data) {
return nil, false
}
return boxes, true
}
// patchChunkOffsets 递归进入 moov 的容器 box,把 stco/co64 的每个偏移 += delta。
func patchChunkOffsets(box []byte, delta int64) bool {
if len(box) < 8 {
return false
}
p := 8 // 跳过自身 box 头(moov 用 32 位 size,极少 64 位;若 64 位则下面 walk 仍从 8 起会错→由调用方 moov 头守恒保证)
for p+8 <= len(box) {
size := int(binary.BigEndian.Uint32(box[p : p+4]))
typ := string(box[p+4 : p+8])
hdr := 8
switch {
case size == 1:
if p+16 > len(box) {
return false
}
size = int(binary.BigEndian.Uint64(box[p+8 : p+16]))
hdr = 16
case size == 0:
size = len(box) - p
}
if size < hdr || p+size > len(box) {
return false
}
child := box[p : p+size]
switch typ {
case "stco":
if !patchStco(child, delta) {
return false
}
case "co64":
if !patchCo64(child, delta) {
return false
}
case "trak", "mdia", "minf", "stbl", "edts":
if !patchChunkOffsets(child, delta) {
return false
}
}
p += size
}
return true
}
// patchStco:stco = [size(4)][type(4)][version+flags(4)][entry_count(4)][offsets 4*count]。
func patchStco(box []byte, delta int64) bool {
if len(box) < 16 {
return false
}
count := binary.BigEndian.Uint32(box[12:16])
off := 16
if int64(off)+int64(count)*4 > int64(len(box)) {
return false
}
for i := uint32(0); i < count; i++ {
v := binary.BigEndian.Uint32(box[off : off+4])
binary.BigEndian.PutUint32(box[off:off+4], uint32(int64(v)+delta))
off += 4
}
return true
}
// patchCo64:co64 entries 为 8 字节。
func patchCo64(box []byte, delta int64) bool {
if len(box) < 16 {
return false
}
count := binary.BigEndian.Uint32(box[12:16])
off := 16
if int64(off)+int64(count)*8 > int64(len(box)) {
return false
}
for i := uint32(0); i < count; i++ {
v := binary.BigEndian.Uint64(box[off : off+8])
binary.BigEndian.PutUint64(box[off:off+8], v+uint64(delta))
off += 8
}
return true
}

View file

@ -0,0 +1,205 @@
package files
import (
"bytes"
"encoding/binary"
"testing"
)
// box 构造一个 MP4 box:[size(4)][type(4)][payload]。
func box(typ string, payload []byte) []byte {
b := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(b[0:4], uint32(8+len(payload)))
copy(b[4:8], typ)
copy(b[8:], payload)
return b
}
// stcoBox 构造一个含单个 chunk 偏移的 stco:[ver+flags(4)][count(4)=1][offset(4)]。
func stcoBox(offset uint32) []byte {
p := make([]byte, 12)
binary.BigEndian.PutUint32(p[4:8], 1) // entry_count
binary.BigEndian.PutUint32(p[8:12], offset)
return box("stco", p)
}
// buildMoovEndMP4 构造一个 moov 在末尾、stco 指向 mdat 内某偏移的最小合法 MP4。
// 返回 (mp4, mdatPayloadAbsOffset)。
func buildMoovEndMP4(mdatPayload []byte) ([]byte, uint32) {
ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00"))
mdat := box("mdat", mdatPayload)
mdatAbs := uint32(len(ftyp) + 8) // mdat payload 紧跟 mdat 头(8 字节)
// moov→trak→mdia→minf→stbl→stco(offset=mdatAbs)
stbl := box("stbl", stcoBox(mdatAbs))
minf := box("minf", stbl)
mdia := box("mdia", minf)
trak := box("trak", mdia)
moov := box("moov", trak)
out := append([]byte(nil), ftyp...)
out = append(out, mdat...)
out = append(out, moov...)
return out, mdatAbs
}
func TestFaststartMP4MovesMoovAndFixesOffsets(t *testing.T) {
marker := []byte("THE-REAL-CHUNK-DATA-HERE")
// mdat payload:前面填充 + marker,stco 指向 marker 的绝对偏移。
pad := bytes.Repeat([]byte{0xAB}, 40)
mdatPayload := append(append([]byte(nil), pad...), marker...)
_, mdatAbs := buildMoovEndMP4(mdatPayload)
markerAbs := mdatAbs + uint32(len(pad)) // marker 在原文件里的绝对偏移
// 原 stco 指向 mdatAbs(mdat payload 头)。这里把 stco 改成指向 marker 以便断言。
in2, _ := buildMoovEndMP4Marker(mdatPayload, markerAbs)
// 校验前置:原文件里 markerAbs 处确实是 marker。
if !bytes.Equal(in2[markerAbs:markerAbs+uint32(len(marker))], marker) {
t.Fatalf("setup: 原文件 markerAbs 处非 marker")
}
out, changed := faststartMP4(in2)
if !changed {
t.Fatalf("changed = false, want true(moov 在末尾应被搬动)")
}
if len(out) != len(in2) {
t.Fatalf("size 不守恒: in=%d out=%d", len(in2), len(out))
}
// 输出顺序:ftyp, moov, mdat。
boxes, ok := parseTopLevelBoxes(out)
if !ok || len(boxes) != 3 || boxes[0].typ != "ftyp" || boxes[1].typ != "moov" || boxes[2].typ != "mdat" {
t.Fatalf("输出顶层顺序错: %+v ok=%v", boxes, ok)
}
// 取出输出 moov 里的 stco 偏移,应指向输出文件里仍是 marker 的位置。
newOff := readSingleStcoOffset(t, out[boxes[1].start:boxes[1].end])
if int(newOff)+len(marker) > len(out) {
t.Fatalf("新偏移越界: %d", newOff)
}
got := out[newOff : int(newOff)+len(marker)]
if !bytes.Equal(got, marker) {
t.Fatalf("新 stco 偏移 %d 指向 %q, want %q(偏移修正错误)", newOff, got, marker)
}
// 新偏移 = 原偏移 + moovSize。
moovSize := boxes[1].end - boxes[1].start
if int(newOff) != int(markerAbs)+moovSize {
t.Fatalf("新偏移 = %d, want 原 %d + moovSize %d", newOff, markerAbs, moovSize)
}
}
func TestFaststartMP4NoopWhenAlreadyFaststart(t *testing.T) {
// moov 在 mdat 前 → 已 faststart,应原样返回。
ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00"))
stbl := box("stbl", stcoBox(100))
moov := box("moov", box("trak", box("mdia", box("minf", stbl))))
mdat := box("mdat", bytes.Repeat([]byte{1}, 64))
in := append(append(append([]byte(nil), ftyp...), moov...), mdat...)
out, changed := faststartMP4(in)
if changed {
t.Fatalf("已 faststart 不应改动")
}
if !bytes.Equal(out, in) {
t.Fatalf("应原样返回")
}
}
func TestInspectMP4LayoutDetectsMoovAtEnd(t *testing.T) {
in, _ := buildMoovEndMP4(bytes.Repeat([]byte{0xCD}, 100))
readAt := func(off, n int64) ([]byte, error) {
if off+n > int64(len(in)) {
n = int64(len(in)) - off
}
return in[off : off+n], nil
}
l, ok := inspectMP4Layout(int64(len(in)), readAt)
if !ok {
t.Fatalf("inspect 失败")
}
if !l.needsFaststart {
t.Fatalf("moov 在末尾应 needsFaststart=true")
}
if !l.moovIsLast {
t.Fatalf("moov 是最后一个 box 应 moovIsLast=true")
}
if l.ftypStart != 0 || l.moovEnd != int64(len(in)) {
t.Fatalf("range 不对: %+v (len=%d)", l, len(in))
}
}
func TestInspectMP4LayoutNoFaststartWhenMoovFirst(t *testing.T) {
ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00"))
moov := box("moov", box("trak", box("mdia", box("minf", box("stbl", stcoBox(100))))))
mdat := box("mdat", bytes.Repeat([]byte{1}, 100))
in := append(append(append([]byte(nil), ftyp...), moov...), mdat...)
readAt := func(off, n int64) ([]byte, error) {
if off+n > int64(len(in)) {
n = int64(len(in)) - off
}
return in[off : off+n], nil
}
l, ok := inspectMP4Layout(int64(len(in)), readAt)
if !ok || l.needsFaststart {
t.Fatalf("moov 在前应 needsFaststart=false, got ok=%v %+v", ok, l)
}
}
// 流式拼接(ftyp + patched moov + 中段)必须与全量 faststartMP4 输出逐字节一致。
func TestStreamingAssemblyMatchesFullRewrite(t *testing.T) {
in, _ := buildMoovEndMP4(bytes.Repeat([]byte{0xEE}, 256))
readAt := func(off, n int64) ([]byte, error) { return in[off : off+n], nil }
l, ok := inspectMP4Layout(int64(len(in)), readAt)
if !ok || !l.needsFaststart || !l.moovIsLast {
t.Fatalf("setup: %+v ok=%v", l, ok)
}
// 流式版本:ftyp + patched moov + in[ftypEnd:moovStart]
ftyp := append([]byte(nil), in[l.ftypStart:l.ftypEnd]...)
moov := append([]byte(nil), in[l.moovStart:l.moovEnd]...)
if !patchChunkOffsets(moov, l.moovEnd-l.moovStart) {
t.Fatalf("patch 失败")
}
var streaming []byte
streaming = append(streaming, ftyp...)
streaming = append(streaming, moov...)
streaming = append(streaming, in[l.ftypEnd:l.moovStart]...)
full, changed := faststartMP4(in)
if !changed {
t.Fatalf("full 应 changed")
}
if !bytes.Equal(streaming, full) {
t.Fatalf("流式拼接与全量重写不一致: streaming=%d full=%d", len(streaming), len(full))
}
}
func TestFaststartMP4NoopOnNonMP4(t *testing.T) {
for _, data := range [][]byte{
nil,
[]byte("not an mp4 at all"),
{0, 0, 0, 4}, // size 太小
} {
if out, changed := faststartMP4(data); changed || !bytes.Equal(out, data) {
t.Fatalf("非 MP4 应原样不动: changed=%v", changed)
}
}
}
// buildMoovEndMP4Marker 同 buildMoovEndMP4,但 stco 指向给定绝对偏移。
func buildMoovEndMP4Marker(mdatPayload []byte, stcoOffset uint32) ([]byte, uint32) {
ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00"))
mdat := box("mdat", mdatPayload)
mdatAbs := uint32(len(ftyp) + 8)
stbl := box("stbl", stcoBox(stcoOffset))
moov := box("moov", box("trak", box("mdia", box("minf", stbl))))
out := append([]byte(nil), ftyp...)
out = append(out, mdat...)
out = append(out, moov...)
return out, mdatAbs
}
func readSingleStcoOffset(t *testing.T, moov []byte) uint32 {
t.Helper()
idx := bytes.Index(moov, []byte("stco"))
if idx < 0 {
t.Fatalf("moov 里找不到 stco")
}
// stco 头后:type(4) 已在 idx;payload 从 idx+4 起 = ver+flags(4)+count(4)+offset(4)
off := idx + 4 + 4 + 4
return binary.BigEndian.Uint32(moov[off : off+4])
}

View file

@ -7,11 +7,20 @@ import (
"encoding/binary"
"fmt"
"image"
"image/color"
"io"
stddraw "image/draw"
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
_ "image/png" // 注册 png DecodeConfig
"image/png"
"math"
"strings"
"time"
"telesrv/internal/domain"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp" // 注册 webp Decode,用于 custom emoji / sticker 静态缩略图合成
)
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
@ -24,17 +33,10 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT
// UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind.
func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, 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))
photo, err := s.CreateAvatarFromUpload(ctx, file)
if err != nil {
return domain.Photo{}, err
}
@ -56,6 +58,14 @@ func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.Uploade
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data))
}
// CreatePhotoFromBytes stores already-fetched image bytes as a message Photo.
func (s *Service) CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) {
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)
@ -79,25 +89,137 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
}
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
func (s *Service) CreateAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) {
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, nil)
}
// CreateAvatarVideoMarkupFromUpload stores Android-style generated avatar video plus its emoji/sticker markup.
func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) {
if err := validateAvatarMarkupSize(markup); err != nil {
return domain.Photo{}, err
}
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup})
}
func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Photo{}, err
}
if body.Size == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
photoID := randomID()
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:u", photoID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: body.ObjectKey,
Size: body.Size,
SHA256: body.SHA256,
MimeType: "video/mp4",
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.Photo{}, err
}
s.blobCache.put(blob.LocationKey, blob)
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindVideo,
Type: "u",
W: 640,
H: 640,
Size: int(body.Size),
VideoStartTs: videoStartTs,
})
sizes = append(sizes, extraSizes...)
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
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup assembled avatar video upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("photo_id", photoID),
zap.Error(err))
}
return photo, nil
}
// CreateAvatarMarkup stores an emoji/sticker animated profile markup as photo.video_sizes.
func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize) (domain.Photo, error) {
if err := validateAvatarMarkupSize(size); err != nil {
return domain.Photo{}, err
}
photoID := randomID()
stillBytes := s.generatedAvatarStill(ctx, size)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
sizes = append(sizes, size)
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func validateAvatarMarkupSize(size domain.PhotoSize) error {
switch size.Kind {
case domain.PhotoSizeKindVideoEmojiMarkup:
if size.EmojiID == 0 || len(size.BackgroundColors) == 0 {
return domain.ErrPhotoInvalid
}
case domain.PhotoSizeKindVideoStickerMarkup:
if size.StickerID == 0 || len(size.BackgroundColors) == 0 {
return domain.ErrPhotoInvalid
}
default:
return domain.ErrPhotoInvalid
}
return nil
}
// 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)
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Document{}, err
}
if len(data) == 0 {
if body.Size == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, err
}
// faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端
// 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径
// 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。
body = s.maybeFaststartVideoBlob(ctx, spec.MimeType, body)
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)),
ObjectKey: body.ObjectKey,
Size: body.Size,
SHA256: body.SHA256,
MimeType: spec.MimeType,
}); err != nil {
return domain.Document{}, err
@ -108,28 +230,183 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
MimeType: spec.MimeType,
Size: int64(len(data)),
Size: body.Size,
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 thumb, err := s.putDocumentThumb(ctx, docID, thumbData); err == nil {
doc.Thumbs = []domain.PhotoSize{thumb}
}
}
}
if len(doc.Thumbs) == 0 {
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, body.ObjectKey, body.Size, spec); ok {
doc.Thumbs = []domain.PhotoSize{thumb}
}
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup assembled document upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("document_id", docID),
zap.Error(err))
}
return doc, nil
}
// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart
// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。
const maxFaststartBytes = 200 << 20
// faststartVideoMimes 是会用 moov/mdat 结构、值得尝试 faststart 的容器 mime。
// 其它 video/*(webm 等)结构不同,faststartMP4 也会自检后 no-op,故不在此列以免白读 blob。
var faststartVideoMimes = map[string]bool{
"video/mp4": true,
"video/quicktime": true,
"video/x-m4v": true,
}
// maybeFaststartVideoBlob 对 MP4/MOV 视频上传尝试 faststart。性能考量:
// 1. 先只读顶层 box 头(廉价探测)判断是否需要——绝大多数客户端上传的视频本就已 faststart,
// 此时只发生几次 16 字节读,不读整段媒体。
// 2. 仅 moov 在末尾时才重写;且优先走流式(仅 ftyp+moov 进内存,mdat 大块分块流式拼接),
// 不把整段视频 2× 驻留内存。moov 非末尾的罕见排布回退到全量重排。
// 任何不适用/失败都返回原 body,绝不让上传失败或损坏数据。
func (s *Service) maybeFaststartVideoBlob(ctx context.Context, mimeType string, body assembledUploadBlob) assembledUploadBlob {
if !faststartVideoMimes[strings.ToLower(strings.TrimSpace(mimeType))] {
return body
}
if body.Size <= 0 || body.Size > maxFaststartBytes {
return body
}
readAt := func(off, n int64) ([]byte, error) {
data, _, err := s.blobs.GetRange(ctx, body.ObjectKey, off, n)
return data, err
}
layout, ok := inspectMP4Layout(body.Size, readAt)
if !ok || !layout.needsFaststart {
return body // 非 MP4 / 已 faststart —— 未读整段媒体
}
var reader io.Reader
if layout.moovIsLast {
// 流式重写:只把 ftyp + moov 读进内存并 patch 偏移,mdat 区段分块流式。
ftyp, e1 := readAt(layout.ftypStart, layout.ftypEnd-layout.ftypStart)
moov, e2 := readAt(layout.moovStart, layout.moovEnd-layout.moovStart)
moovSize := layout.moovEnd - layout.moovStart
if e1 != nil || e2 != nil ||
int64(len(ftyp)) != layout.ftypEnd-layout.ftypStart ||
int64(len(moov)) != moovSize ||
!patchChunkOffsets(moov, moovSize) {
return body
}
mid := &blobRangeReader{ctx: ctx, blobs: s.blobs, key: body.ObjectKey, pos: layout.ftypEnd, end: layout.moovStart}
reader = io.MultiReader(bytes.NewReader(ftyp), bytes.NewReader(moov), mid)
} else {
// 罕见:moov 非末尾。回退到全量读 + 重排(已测函数)。
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err != nil || total != body.Size || int64(len(data)) != body.Size {
return body
}
out, changed := faststartMP4(data)
if !changed {
return body
}
reader = bytes.NewReader(out)
}
key, size, sum, err := s.blobs.PutReader(ctx, reader)
if err != nil {
s.log.Warn("faststart re-store failed; keeping original blob",
zap.String("mime", mimeType), zap.Int64("size", body.Size), zap.Error(err))
return body
}
if size != body.Size {
// faststart 守恒大小;不等说明流式拼接出错,丢弃新 blob 用原 blob 兜底。
s.log.Warn("faststart size mismatch; keeping original blob",
zap.Int64("orig", body.Size), zap.Int64("got", size))
return body
}
s.log.Info("faststart applied to uploaded video",
zap.String("mime", mimeType), zap.Int64("size", size))
return assembledUploadBlob{ObjectKey: key, Size: size, SHA256: sum}
}
// blobRangeReader 把 blob 的 [pos, end) 区段按 io.Reader 调用方给的缓冲大小分块流式读出,
// 用于 faststart 流式拼接 mdat,避免整段媒体驻留内存。
type blobRangeReader struct {
ctx context.Context
blobs BlobBackend
key string
pos int64
end int64
}
func (r *blobRangeReader) Read(p []byte) (int, error) {
if r.pos >= r.end {
return 0, io.EOF
}
want := r.end - r.pos
if want > int64(len(p)) {
want = int64(len(p))
}
data, _, err := r.blobs.GetRange(r.ctx, r.key, r.pos, want)
if err != nil {
return 0, err
}
if len(data) == 0 {
return 0, io.ErrUnexpectedEOF // 区段内不应读到空,避免静默截断
}
n := copy(p, data)
r.pos += int64(n)
return n, nil
}
// CreateDocumentFromBytes stores already-fetched bytes as a message Document.
func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) {
if len(data) == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
if strings.TrimSpace(spec.MimeType) == "" {
spec.MimeType = "application/octet-stream"
}
objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
if err != nil {
return domain.Document{}, err
}
if size == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
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: size,
SHA256: sum,
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: size,
DCID: s.dc,
Attributes: spec.Attributes,
}
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, objectKey, size, spec); ok {
doc.Thumbs = []domain.PhotoSize{thumb}
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
@ -177,19 +454,7 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp
// GetProfilePhotosKind returns profile/fallback photo history.
func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, 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
return s.media.ListProfilePhotoDetailsKind(ctx, ownerType, ownerID, kind, offset, limit, maxID)
}
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
@ -208,24 +473,11 @@ func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.
// 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)
photoID := randomID()
sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, specs)
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(),
@ -240,6 +492,116 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
return photo, nil
}
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return nil, err
}
mimeType := imageMimeType(data)
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: mimeType,
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
}
s.prewarmSmallBlob(objectKey, data)
return sizes, nil
}
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
if len(thumbData) == 0 {
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
}
thumbKey, err := s.blobs.Put(ctx, thumbData)
if err != nil {
return domain.PhotoSize{}, err
}
w, h := imageDimensions(thumbData, 0, 0)
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:m", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: thumbKey,
Size: int64(len(thumbData)),
MimeType: "image/jpeg",
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.PhotoSize{}, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(thumbKey, thumbData)
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}, nil
}
func (s *Service) generateVideoThumbFallback(ctx context.Context, docID int64, data []byte, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
if s.thumbs == nil || !documentSpecIsVideo(spec) || len(data) > videoThumbnailMaxInputBytes {
return domain.PhotoSize{}, false
}
thumbData, err := s.thumbs.Extract(ctx, data, spec.MimeType)
if err != nil {
s.log.Warn("server-side video thumbnail fallback failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("bytes", int64(len(data))),
zap.Error(err))
return domain.PhotoSize{}, false
}
thumb, err := s.putDocumentThumb(ctx, docID, thumbData)
if err != nil {
s.log.Warn("store server-side video thumbnail failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("thumb_bytes", int64(len(thumbData))),
zap.Error(err))
return domain.PhotoSize{}, false
}
return thumb, true
}
func (s *Service) generateVideoThumbFallbackFromBlob(ctx context.Context, docID int64, objectKey string, size int64, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
if s.thumbs == nil || !documentSpecIsVideo(spec) || size > videoThumbnailMaxInputBytes {
return domain.PhotoSize{}, false
}
data, total, err := s.blobs.GetRange(ctx, objectKey, 0, size)
if err != nil {
s.log.Warn("read video blob for thumbnail fallback failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("bytes", size),
zap.Error(err))
return domain.PhotoSize{}, false
}
if int64(len(data)) != total || total != size {
s.log.Warn("video blob size mismatch for thumbnail fallback",
zap.Int64("document_id", docID),
zap.Int64("expected_size", size),
zap.Int64("total_size", total),
zap.Int("read_bytes", len(data)))
return domain.PhotoSize{}, false
}
return s.generateVideoThumbFallback(ctx, docID, data, spec)
}
func documentSpecIsVideo(spec domain.DocumentSpec) bool {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(spec.MimeType)), "video/") {
return true
}
for _, attr := range spec.Attributes {
if attr.Kind == domain.DocAttrVideo {
return true
}
}
return false
}
type photoSizeSpec struct {
Type string
W int
@ -286,6 +648,281 @@ func scaleDown(w, h, max int) (int, int) {
return max * w / h, max
}
const (
avatarStillSize = 640
avatarMarkupScale = 0.70
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
)
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像
// (emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色
// emoji、圆角、布局都一致);抽帧不可用时回退到按 markup 服务端合成。
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err == nil && int64(len(data)) == total && total == body.Size {
if thumb, err := s.thumbs.Extract(ctx, data, "video/mp4"); err == nil && len(thumb) > 0 {
return thumb
} else if err != nil {
s.log.Debug("extract avatar video first frame failed, falling back to composed still",
zap.String("object_key", body.ObjectKey),
zap.Int64("bytes", body.Size),
zap.Error(err))
}
} else if err != nil {
s.log.Warn("read avatar video blob for still failed",
zap.String("object_key", body.ObjectKey),
zap.Int64("bytes", body.Size),
zap.Error(err))
}
}
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes))
}
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
img := generatedAvatarBackground(markup.BackgroundColors)
if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok {
drawAvatarMarkup(img, overlay, tintWhite)
}
return encodeAvatarPNG(img)
}
func generatedAvatarBackground(colors []int) *image.RGBA {
if len(colors) == 0 {
colors = []int{0x5b8def, 0x53c6a4}
}
first := rgbColor(colors[0])
last := first
if len(colors) > 1 {
last = rgbColor(colors[len(colors)-1])
}
img := image.NewRGBA(image.Rect(0, 0, avatarStillSize, avatarStillSize))
for y := 0; y < avatarStillSize; y++ {
t := float64(y) / float64(avatarStillSize-1)
row := lerpColor(first, last, t)
for x := 0; x < avatarStillSize; x++ {
img.SetRGBA(x, y, row)
}
}
return img
}
func encodeAvatarPNG(img image.Image) []byte {
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// avatarMarkupOverlay 加载 markup 引用文档的静态缩略图作为合成贴图。第二个返回值
// 表示是否染白:仅 text_color 的 custom emoji(单色、由客户端按文字色适配渲染,
// 头像背景上的约定呈现是白色剪影)需要染白,普通彩色 emoji / sticker 保留原色。
func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSize) (image.Image, bool, bool) {
if s == nil || s.media == nil {
return nil, false, false
}
docID := int64(0)
switch markup.Kind {
case domain.PhotoSizeKindVideoEmojiMarkup:
docID = markup.EmojiID
case domain.PhotoSizeKindVideoStickerMarkup:
docID = markup.StickerID
default:
return nil, false, false
}
if docID == 0 {
return nil, false, false
}
doc, found, err := s.media.GetDocument(ctx, docID)
if err != nil {
s.log.Warn("load avatar markup document failed", zap.Int64("document_id", docID), zap.Error(err))
return nil, false, false
}
if !found {
return nil, false, false
}
data, ok := s.avatarMarkupBytes(ctx, doc)
if !ok {
return nil, false, false
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
s.log.Debug("decode avatar markup thumbnail failed",
zap.Int64("document_id", doc.ID),
zap.String("mime_type", doc.MimeType),
zap.Error(err))
return nil, false, false
}
return img, documentIsTextColorEmoji(doc), true
}
// documentIsTextColorEmoji 判断文档是否为声明 text_color 的 custom emoji。
func documentIsTextColorEmoji(doc domain.Document) bool {
for _, attr := range doc.Attributes {
if attr.Kind == domain.DocAttrCustomEmoji {
return attr.TextColor
}
}
return false
}
func (s *Service) avatarMarkupBytes(ctx context.Context, doc domain.Document) ([]byte, bool) {
var best []byte
bestScore := -1
for _, thumb := range doc.Thumbs {
score := avatarThumbScore(thumb)
if score <= bestScore {
continue
}
switch thumb.Kind {
case domain.PhotoSizeKindCached:
if len(thumb.Bytes) == 0 || len(thumb.Bytes) > avatarMarkupMaxSourceBytes {
continue
}
best = append([]byte(nil), thumb.Bytes...)
bestScore = score
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
if thumb.Type == "" || thumb.Size <= 0 || thumb.Size > avatarMarkupMaxSourceBytes {
continue
}
data, ok := s.readSmallBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type), int64(thumb.Size))
if !ok {
continue
}
best = data
bestScore = score
}
}
if len(best) > 0 {
return best, true
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(doc.MimeType)), "image/") &&
doc.Size > 0 && doc.Size <= avatarMarkupMaxSourceBytes {
return s.readSmallBlob(ctx, fmt.Sprintf("doc:%d", doc.ID), doc.Size)
}
return nil, false
}
func (s *Service) readSmallBlob(ctx context.Context, locationKey string, expectedSize int64) ([]byte, bool) {
if expectedSize <= 0 || expectedSize > avatarMarkupMaxSourceBytes {
return nil, false
}
blob, found, err := s.media.GetFileBlob(ctx, locationKey)
if err != nil {
s.log.Warn("load avatar markup blob metadata failed",
zap.String("location_key", locationKey),
zap.Error(err))
return nil, false
}
if !found || blob.Size <= 0 || blob.Size > avatarMarkupMaxSourceBytes {
return nil, false
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
s.log.Warn("read avatar markup blob failed",
zap.String("location_key", locationKey),
zap.String("object_key", blob.ObjectKey),
zap.Error(err))
return nil, false
}
if int64(len(data)) != total || total != blob.Size {
return nil, false
}
return data, true
}
func avatarThumbScore(size domain.PhotoSize) int {
if size.W > 0 && size.H > 0 {
return size.W * size.H
}
if size.Size > 0 {
return size.Size
}
return len(size.Bytes)
}
// drawAvatarMarkup 把贴图缩放后居中画到背景上。默认保留贴图原色(彩色 emoji /
// sticker 头像),仅 tintWhite 时染成白色剪影。
func drawAvatarMarkup(dst *image.RGBA, src image.Image, tintWhite bool) {
srcBounds := src.Bounds()
sw, sh := srcBounds.Dx(), srcBounds.Dy()
if sw <= 0 || sh <= 0 {
return
}
max := int(math.Round(float64(dst.Bounds().Dx()) * avatarMarkupScale))
if max <= 0 {
return
}
scale := math.Min(float64(max)/float64(sw), float64(max)/float64(sh))
w := maxInt(1, int(math.Round(float64(sw)*scale)))
h := maxInt(1, int(math.Round(float64(sh)*scale)))
rect := image.Rect(
(dst.Bounds().Dx()-w)/2,
(dst.Bounds().Dy()-h)/2,
(dst.Bounds().Dx()+w)/2,
(dst.Bounds().Dy()+h)/2,
)
scaled := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, srcBounds, xdraw.Src, nil)
if tintWhite {
tintWhitePremultiplied(scaled)
}
stddraw.Draw(dst, rect, scaled, image.Point{}, stddraw.Over)
}
// tintWhitePremultiplied 把贴图就地染成白色剪影(保留 alpha 形状)。image.RGBA 是
// alpha 预乘存储,分量必须满足 R,G,B ≤ A:若写入 R=G=B=255 而 A<255 的非法值,
// draw.Over 合成会算术溢出回绕,凡 alpha 不恰为 255 的像素整片输出近黑色。
func tintWhitePremultiplied(img *image.RGBA) {
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
v := uint8(a >> 8)
img.SetRGBA(x, y, color.RGBA{R: v, G: v, B: v, A: v})
}
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func rgbColor(v int) color.RGBA {
u := uint32(v)
return color.RGBA{R: uint8(u >> 16), G: uint8(u >> 8), B: uint8(u), A: 255}
}
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
lerp := func(x, y uint8) uint8 {
return uint8(float64(x)*(1-t) + float64(y)*t)
}
return color.RGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: 255}
}
func imageMimeType(data []byte) string {
if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return "image/png"
}
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
return "image/jpeg"
}
return "application/octet-stream"
}
func avatarStillMarkup(sizes []domain.PhotoSize) domain.PhotoSize {
for _, size := range sizes {
if size.Kind == domain.PhotoSizeKindVideoEmojiMarkup || size.Kind == domain.PhotoSizeKindVideoStickerMarkup {
return size
}
if len(size.BackgroundColors) > 0 {
return domain.PhotoSize{BackgroundColors: append([]int(nil), size.BackgroundColors...)}
}
}
return domain.PhotoSize{}
}
func randomID() int64 {
var b [8]byte
_, _ = rand.Read(b[:])

View file

@ -0,0 +1,466 @@
package files
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
"telesrv/internal/domain"
)
func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
thumbBytes := testJPEG(t, 4, 2)
thumbnailer := &fakeVideoThumbnailer{thumb: thumbBytes}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("fake-video-bytes")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
doc, err := svc.CreateDocumentFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 100, Parts: 1, Name: "video.mp4"},
domain.DocumentSpec{
MimeType: "video/mp4",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
})
if err != nil {
t.Fatalf("CreateDocumentFromUpload: %v", err)
}
if thumbnailer.calls != 1 {
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
}
if len(doc.Thumbs) != 1 {
t.Fatalf("thumbs = %+v, want one generated thumbnail", doc.Thumbs)
}
if got := doc.Thumbs[0]; got.Type != "m" || got.W != 4 || got.H != 2 || got.Size != len(thumbBytes) {
t.Fatalf("thumb = %+v, want m 4x2 size=%d", got, len(thumbBytes))
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", doc.ID))
if err != nil || !ok {
t.Fatalf("generated thumb blob ok=%v err=%v", ok, err)
}
gotBytes, err := blobs.Get(ctx, blob.ObjectKey)
if err != nil {
t.Fatalf("read generated thumb blob: %v", err)
}
if !bytes.Equal(gotBytes, thumbBytes) {
t.Fatalf("generated thumb bytes mismatch")
}
}
func TestCreateDocumentFromUploadKeepsClientThumb(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
thumbnailer := &fakeVideoThumbnailer{err: errors.New("should not be called")}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
clientThumb := testJPEG(t, 3, 5)
if _, err := svc.SaveFilePart(ctx, 10, 200, 0, []byte("fake-video-bytes")); err != nil {
t.Fatalf("SaveFilePart video: %v", err)
}
if _, err := svc.SaveFilePart(ctx, 10, 201, 0, clientThumb); err != nil {
t.Fatalf("SaveFilePart thumb: %v", err)
}
doc, err := svc.CreateDocumentFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 200, Parts: 1, Name: "video.mp4"},
domain.DocumentSpec{
MimeType: "video/mp4",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
Thumb: &domain.UploadedFileRef{OwnerUserID: 10, FileID: 201, Parts: 1, Name: "thumb.jpg"},
})
if err != nil {
t.Fatalf("CreateDocumentFromUpload: %v", err)
}
if thumbnailer.calls != 0 {
t.Fatalf("thumbnailer calls = %d, want 0 when client thumb is available", thumbnailer.calls)
}
if len(doc.Thumbs) != 1 {
t.Fatalf("thumbs = %+v, want client thumbnail", doc.Thumbs)
}
if got := doc.Thumbs[0]; got.W != 3 || got.H != 5 || got.Size != len(clientThumb) {
t.Fatalf("thumb = %+v, want client thumb dimensions", got)
}
}
func TestCreateDocumentFromUploadWithoutThumbnailerDoesNotBlockVideo(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil))
if _, err := svc.SaveFilePart(ctx, 10, 300, 0, []byte("fake-video-bytes")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
doc, err := svc.CreateDocumentFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 300, Parts: 1, Name: "video.mp4"},
domain.DocumentSpec{
MimeType: "video/mp4",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
})
if err != nil {
t.Fatalf("CreateDocumentFromUpload without thumbnailer: %v", err)
}
if len(doc.Thumbs) != 0 {
t.Fatalf("thumbs = %+v, want no fallback thumbnail when thumbnailer is disabled", doc.Thumbs)
}
}
func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
data := testJPEG(t, 16, 9)
photo, err := svc.CreatePhotoFromBytes(ctx, data)
if err != nil {
t.Fatalf("CreatePhotoFromBytes: %v", err)
}
if photo.ID == 0 || photo.AccessHash == 0 || photo.DCID != 2 || len(photo.Sizes) != 2 {
t.Fatalf("photo = %+v, want stored photo with message sizes", photo)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:x", photo.ID))
if err != nil || !ok {
t.Fatalf("photo blob ok=%v err=%v", ok, err)
}
got, err := blobs.Get(ctx, blob.ObjectKey)
if err != nil {
t.Fatalf("read photo blob: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("photo blob bytes mismatch")
}
}
func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil))
data := []byte("inline document body")
spec := domain.DocumentSpec{
MimeType: "application/pdf",
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrFilename, FileName: "inline.pdf"},
},
}
doc, err := svc.CreateDocumentFromBytes(ctx, data, spec)
if err != nil {
t.Fatalf("CreateDocumentFromBytes: %v", err)
}
if doc.ID == 0 || doc.AccessHash == 0 || doc.Size != int64(len(data)) || doc.MimeType != "application/pdf" || len(doc.Attributes) != 1 {
t.Fatalf("document = %+v, want stored document body and attributes", doc)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
if err != nil || !ok {
t.Fatalf("document blob ok=%v err=%v", ok, err)
}
got, err := blobs.Get(ctx, blob.ObjectKey)
if err != nil {
t.Fatalf("read document blob: %v", err)
}
if !bytes.Equal(got, data) || blob.MimeType != "application/pdf" {
t.Fatalf("document blob mime=%q bytes=%q", blob.MimeType, got)
}
}
func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: 99,
BackgroundColors: []int{0xff3b30, 0x34c759},
})
if err != nil {
t.Fatalf("CreateAvatarMarkup: %v", err)
}
if !domain.PhotoHasVideo(photo.Sizes) {
t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
}
// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为:
// 1. 普通彩色 emoji 合成进静态头像时保留原色(不得染白/变黑);
// 2. 贴图含非满 alpha 像素(抗锯齿常态)时不得因预乘溢出整片变黑——曾因把
// R=G=B=255、A<255 的非法预乘值喂给 draw.Over 溢出回绕,emoji 输出近黑色。
func TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(99)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached,
Type: "m",
W: 64,
H: 64,
Bytes: testTransparentThumbPNG(t),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
svc := NewService(media, blobs, 2)
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: emojiID,
BackgroundColors: []int{0x112233, 0x445566},
})
if err != nil {
t.Fatalf("CreateAvatarMarkup: %v", err)
}
r, g, b, a := avatarStillCenterPixel(t, svc, photo.ID)
if a < 250 {
t.Fatalf("center pixel alpha=%d, want opaque still", a)
}
if r < 200 || g > 90 || b > 90 {
t.Fatalf("center pixel rgb=(%d,%d,%d), want red emoji color preserved", r, g, b)
}
}
// TestCreateAvatarMarkupTintsTextColorEmojiWhite 守护 text_color custom emoji 的
// 白色剪影呈现:染色必须写合法预乘值(R=G=B=A),非满 alpha 像素不得溢出变黑。
func TestCreateAvatarMarkupTintsTextColorEmojiWhite(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(120)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrCustomEmoji,
TextColor: true,
}},
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached,
Type: "m",
W: 64,
H: 64,
Bytes: testTransparentThumbPNG(t),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
svc := NewService(media, blobs, 2)
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: emojiID,
BackgroundColors: []int{0x112233, 0x445566},
})
if err != nil {
t.Fatalf("CreateAvatarMarkup: %v", err)
}
r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID)
if r < 230 || g < 230 || b < 230 {
t.Fatalf("center pixel rgb=(%d,%d,%d), want white silhouette for text_color emoji", r, g, b)
}
}
func avatarStillCenterPixel(t *testing.T, svc *Service, photoID int64) (r, g, b, a uint32) {
t.Helper()
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:c", photoID),
Offset: 0,
Limit: 1 << 20,
})
if err != nil || !found {
t.Fatalf("avatar c blob found=%v err=%v", found, err)
}
img, _, err := image.Decode(bytes.NewReader(chunk.Bytes))
if err != nil {
t.Fatalf("decode avatar still: %v", err)
}
r, g, b, a = img.At(avatarStillSize/2, avatarStillSize/2).RGBA()
return r >> 8, g >> 8, b >> 8, a >> 8
}
func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
if _, err := svc.SaveFilePart(ctx, 10, 400, 0, []byte("fake-profile-video")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 400, Parts: 1, Name: "avatar.mp4"},
0.25,
domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: 100,
BackgroundColors: []int{0x536dfe, 0x26a69a},
})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:u", photo.ID),
Offset: 0,
Limit: 1024,
})
if err != nil || !found {
t.Fatalf("video avatar blob found=%v err=%v", found, err)
}
if string(chunk.Bytes) != "fake-profile-video" {
t.Fatalf("video avatar bytes = %q", chunk.Bytes)
}
}
// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取
// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still。
func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
frame := testJPEG(t, 640, 640)
thumbnailer := &fakeVideoThumbnailer{thumb: frame}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 500, 0, []byte("fake-profile-video")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 500, Parts: 1, Name: "avatar.mp4"},
0,
domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: 77,
BackgroundColors: []int{0x112233},
})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 1 {
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
}
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:a", photo.ID),
Offset: 0,
Limit: 1 << 20,
})
if err != nil || !found {
t.Fatalf("avatar a blob found=%v err=%v", found, err)
}
if !bytes.Equal(chunk.Bytes, frame) {
t.Fatalf("avatar still bytes != extracted first frame (got %d bytes, want %d)", len(chunk.Bytes), len(frame))
}
if chunk.MimeType != "image/jpeg" {
t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType)
}
}
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) {
t.Helper()
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType),
Offset: 0,
Limit: 1 << 20,
})
if err != nil || !found {
t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err)
}
if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" {
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes))
}
}
// testTransparentThumbPNG 构造红色方块贴图:周边透明、中心 alpha=250(模拟抗锯齿
// 的非满 alpha),用于守护预乘溢出回归——溢出代码会把 alpha≠255 的像素整片渲染成黑。
func testTransparentThumbPNG(t *testing.T) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
for y := 8; y < 56; y++ {
for x := 8; x < 56; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: 240, G: 30, B: 30, A: 250})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode test thumb: %v", err)
}
return buf.Bytes()
}
type fakeVideoThumbnailer struct {
calls int
thumb []byte
err error
}
func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return append([]byte(nil), f.thumb...), nil
}
func testJPEG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(40 + x), G: uint8(80 + y), B: 120, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}

View file

@ -1,10 +1,14 @@
package files
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"regexp"
@ -13,6 +17,8 @@ import (
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
@ -25,6 +31,7 @@ import (
type SeedStats struct {
Reactions int
StickerSets int
Effects int
Documents int
Blobs int
Skipped bool
@ -38,11 +45,19 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
return stats, nil
}
if _, err := os.Stat(root); err != nil {
// 目录不存在:跳过而非失败(开发机可能未放资源)。
// 目录不存在:跳过而非失败(开发机可能未放资源)。但显式 WARN——否则「配置的 seed 目录
// 不存在 → 静默不导入贴纸/reaction」会被埋没(DB 已有旧数据时尤其隐蔽,表现为客户端反复
// 拉取未 seed 的集)。配置 TELESRV_STICKER_SEED_DIR 指向真实导出目录即可。
if s.log != nil {
s.log.Warn("sticker/reaction seed 目录不存在,跳过媒体种子导入(配置 TELESRV_STICKER_SEED_DIR)",
zap.String("dir", root), zap.Error(err))
}
stats.Skipped = true
return stats, nil
}
phaseStarted := time.Now()
phaseBefore := stats
// reactions
if n, err := s.media.CountAvailableReactions(ctx); err != nil {
return stats, err
@ -57,28 +72,58 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
return stats, fmt.Errorf("repair reactions: %w", err)
}
}
s.logSeedPhase("reactions", phaseStarted, phaseBefore, stats)
// sticker sets(default 系统集 + 常规集)
phaseStarted = time.Now()
phaseBefore = stats
// sticker sets(default 系统集 + 常规集 + emoji 集):始终扫描导出目录以**增量**拾取
// 新增的 set 目录——importStickerSetDir 跳过内容(hash)未变的已有集,只导入新集/变更集。
// 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。
// 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。
forceSticker := false
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)
} else if n > 0 {
stale, err := s.stickerSetDocumentsNeedSeedRepair(ctx)
if err != nil {
return stats, err
}
forceSticker = stale
}
if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil {
return stats, fmt.Errorf("seed sticker sets: %w", err)
}
s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats)
if stats.Reactions == 0 && stats.StickerSets == 0 {
phaseStarted = time.Now()
phaseBefore = stats
// 消息发送特效:全局静态目录,每次启动重建内存 s.effects(文档导入幂等)。
if err := s.seedEffects(ctx, root, &stats); err != nil {
return stats, fmt.Errorf("seed effects: %w", err)
}
s.logSeedPhase("effects", phaseStarted, phaseBefore, stats)
if stats.Reactions == 0 && stats.StickerSets == 0 && stats.Effects == 0 {
stats.Skipped = true
}
return stats, nil
}
func (s *Service) logSeedPhase(phase string, started time.Time, before, after SeedStats) {
if s.log == nil {
return
}
s.log.Info("媒体种子阶段完成",
zap.String("phase", phase),
zap.Duration("elapsed", time.Since(started)),
zap.Int("reactions", after.Reactions-before.Reactions),
zap.Int("sticker_sets", after.StickerSets-before.StickerSets),
zap.Int("effects", after.Effects-before.Effects),
zap.Int("documents", after.Documents-before.Documents),
zap.Int("blobs", after.Blobs-before.Blobs),
)
}
// ---- reactions ----
func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error {
@ -175,7 +220,7 @@ func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, e
// ---- sticker sets ----
func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error {
func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, force bool, stats *SeedStats) error {
// default 系统集:目录名 → system_key。
defaultDir := filepath.Join(root, "telegram_default_stickers_export")
order := 0
@ -190,13 +235,33 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i
for _, name := range names {
systemKey := systemKeyForDefaultSet(name)
setDir := filepath.Join(defaultDir, name)
if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil {
if err := s.importStickerSetDir(ctx, setDir, systemKey, order, force, stats); err != nil {
return fmt.Errorf("import default set %s: %w", name, err)
}
order++
}
}
// custom-emoji 集(telegram_emoji_export/<set>/):不受 maxRegular 限制,按 set_info 的
// emojis 标志归入 StickerSetKindEmoji(getEmojiStickers/getFeaturedEmojiStickers 下发)。
emojiDir := filepath.Join(root, "telegram_emoji_export")
if entries, err := os.ReadDir(emojiDir); 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 {
setDir := filepath.Join(emojiDir, name)
if err := s.importStickerSetDir(ctx, setDir, "", order, force, stats); err != nil {
return fmt.Errorf("import emoji set %s: %w", name, err)
}
order++
}
}
// 常规贴纸集。
regularDir := filepath.Join(root, "telegram_stickers_export")
if entries, err := os.ReadDir(regularDir); err == nil {
@ -213,7 +278,7 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i
break
}
setDir := filepath.Join(regularDir, name)
if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil {
if err := s.importStickerSetDir(ctx, setDir, "", order, force, stats); err != nil {
return fmt.Errorf("import sticker set %s: %w", name, err)
}
order++
@ -223,7 +288,7 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i
return nil
}
func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error {
func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, force bool, stats *SeedStats) error {
infoPath := filepath.Join(setDir, "set_info.json")
raw, err := os.ReadFile(infoPath)
if err != nil {
@ -239,6 +304,16 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str
if sj.ID == 0 {
return nil
}
// 增量 seed:集已存在且内容 hash 未变则跳过(不重读文档/重传 blob)。force 时强制重导
// (缩略图内联缓存修复路径)。这让 seedStickerSets 可在非空 store 上每次启动安全重扫,
// 仅导入新增/变更集。
if !force {
if existing, found, err := s.media.GetStickerSetByID(ctx, sj.ID); err != nil {
return err
} else if found && existing.Hash == sj.Hash {
return nil
}
}
stickersDir := filepath.Join(setDir, "stickers")
index, err := scanSeedDir(stickersDir)
if err != nil {
@ -382,6 +457,10 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
}
doc.Thumbs = thumbs
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
return domain.Document{}, err
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
@ -406,6 +485,7 @@ var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
const seedSyntheticDocumentThumbType = "m"
// Exported Telegram resources keep their original id in filenames/JSON, but
// telesrv owns the document catalog it serves. Imported high source ids are
@ -413,6 +493,7 @@ const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
var seedSyntheticTGStickerPreviewThumbPNG = makeSeedSyntheticTGStickerPreviewThumbPNG()
func seedDocumentStorageID(sourceID int64) int64 {
if sourceID <= 0 {
@ -559,17 +640,6 @@ func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
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 {
@ -612,6 +682,63 @@ func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.Phot
return ps
}
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
return nil
}
if s.blobs == nil {
return fmt.Errorf("blob backend not configured for synthetic sticker preview thumb")
}
data := seedSyntheticTGStickerPreviewThumbPNG
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, seedSyntheticDocumentThumbType),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: "image/png",
}); err != nil {
return err
}
doc.Thumbs = append(doc.Thumbs, domain.PhotoSize{
Kind: domain.PhotoSizeKindCached,
Type: seedSyntheticDocumentThumbType,
W: 1,
H: 1,
Bytes: append([]byte(nil), data...),
})
s.prewarmSmallBlob(objectKey, data)
stats.Blobs++
return nil
}
func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool {
if doc.MimeType != "application/x-tgsticker" || len(doc.Thumbs) > 0 {
return false
}
return seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji)
}
func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
for _, attr := range attrs {
if attr.Kind == kind {
return true
}
}
return false
}
func makeSeedSyntheticTGStickerPreviewThumbPNG() []byte {
var buf bytes.Buffer
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.NRGBA{})
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func seedThumbMimeType(data []byte) string {
switch {
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
@ -628,7 +755,7 @@ func seedThumbMimeType(data []byte) string {
}
}
func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) {
func (s *Service) stickerSetDocumentsNeedSeedRepair(ctx context.Context) (bool, error) {
var ids []int64
for _, kind := range []domain.StickerSetKind{
domain.StickerSetKindStickers,
@ -644,10 +771,14 @@ func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (
ids = append(ids, set.DocumentIDs...)
}
}
return s.documentsNeedInlineCachedThumbs(ctx, ids)
return s.documentsNeedSeedRepair(ctx, ids)
}
func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) {
return s.documentsNeedSeedRepair(ctx, ids)
}
func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (bool, error) {
if len(ids) == 0 {
return false, nil
}
@ -668,6 +799,9 @@ func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int
return false, err
}
for _, doc := range docs {
if seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc) {
return true, nil
}
for _, thumb := range doc.Thumbs {
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
return true, nil

View file

@ -0,0 +1,172 @@
package files
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"sort"
"telesrv/internal/domain"
)
const (
seedEffectsStateKey = "files.effects"
seedEffectsStateVersion = "effects-v2"
seedAppearanceStateKey = "files.appearance"
seedAppearanceStateVersion = "appearance-v1"
)
func (s *Service) seedStateMatches(ctx context.Context, key, want string) (bool, error) {
if want == "" {
return false, nil
}
got, found, err := s.media.GetSeedState(ctx, key)
if err != nil || !found {
return false, err
}
return got == want, nil
}
func (s *Service) putSeedState(ctx context.Context, key, hash string) error {
if hash == "" {
return nil
}
return s.media.PutSeedState(ctx, key, hash)
}
func seedStateHash(write func(hash.Hash) error) (string, error) {
h := sha256.New()
if err := write(h); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func writeSeedStateHeader(h io.Writer, version string, dc int) {
_, _ = fmt.Fprintf(h, "version=%s\ndc=%d\n", version, dc)
}
func writeSeedDirFingerprint(h io.Writer, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
names := make([]string, 0, len(entries))
byName := make(map[string]os.DirEntry, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
names = append(names, entry.Name())
byName[entry.Name()] = entry
}
sort.Strings(names)
for _, name := range names {
info, err := byName[name].Info()
if err != nil {
return err
}
rel := filepath.ToSlash(name)
_, _ = fmt.Fprintf(h, "file=%s\x00size=%d\x00mtime=%d\n", rel, info.Size(), info.ModTime().UnixNano())
}
return nil
}
func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []string {
storageID := seedDocumentStorageID(dj.ID)
if storageID == 0 {
return nil
}
keys := make([]string, 0, 1+len(dj.Thumbs))
if _, ok := index.main[dj.ID]; ok {
keys = append(keys, fmt.Sprintf("doc:%d", storageID))
}
for _, tj := range dj.Thumbs {
ps, downloadable := seedPhotoSize(tj)
if !downloadable || ps.Type == "" {
continue
}
if _, ok := index.thumb[dj.ID][ps.Type]; ok {
keys = append(keys, fmt.Sprintf("doc:%d:%s", storageID, ps.Type))
}
}
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
keys = append(keys, fmt.Sprintf("doc:%d:%s", storageID, seedSyntheticDocumentThumbType))
}
return keys
}
func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool {
if dj.MimeType != "application/x-tgsticker" || len(dj.Thumbs) > 0 {
return false
}
return seedDocumentHasAttribute(seedDocumentAttributes(dj.Attributes), domain.DocAttrCustomEmoji)
}
func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) {
expected := make(map[int64]seedDocumentJSON, len(docs))
ids := make([]int64, 0, len(docs))
locationKeys := make([]string, 0, len(docs))
seenLocationKeys := make(map[string]struct{}, len(docs))
for _, dj := range docs {
storageID := seedDocumentStorageID(dj.ID)
if storageID == 0 {
continue
}
if _, ok := expected[storageID]; !ok {
expected[storageID] = dj
ids = append(ids, storageID)
}
for _, key := range seedDocumentJSONLocationKeys(dj, index) {
if _, ok := seenLocationKeys[key]; ok {
continue
}
seenLocationKeys[key] = struct{}{}
locationKeys = append(locationKeys, key)
}
}
if len(ids) == 0 {
return true, nil
}
stored, err := s.media.GetDocuments(ctx, ids)
if err != nil {
return false, err
}
if len(stored) < len(expected) {
return false, nil
}
for _, doc := range stored {
dj, ok := expected[doc.ID]
if !ok {
continue
}
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
return false, nil
}
delete(expected, doc.ID)
}
if len(expected) > 0 {
return false, nil
}
if len(locationKeys) == 0 {
return true, nil
}
blobs, err := s.media.GetFileBlobs(ctx, locationKeys)
if err != nil {
return false, err
}
for _, key := range locationKeys {
if _, ok := blobs[key]; !ok {
return false, nil
}
}
return true, nil
}

View file

@ -2,10 +2,13 @@ package files
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
@ -19,23 +22,96 @@ type fakeMediaStore struct {
sets map[int64]domain.StickerSet
reactions []domain.AvailableReaction
parts map[string][]domain.UploadPart
webPages map[int64]domain.MessageWebPage
seedState map[string]string
}
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{},
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{},
seedState: map[string]string{},
}
}
func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil }
func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) {
func (f *fakeMediaStore) SaveFilePart(_ context.Context, part domain.UploadPart) error {
f.mu.Lock()
defer f.mu.Unlock()
key := fakeUploadPartKey(part.OwnerUserID, part.FileID)
part.SHA256 = append([]byte(nil), part.SHA256...)
parts := f.parts[key]
for i := range parts {
if parts[i].Part == part.Part {
parts[i] = part
f.parts[key] = parts
return nil
}
}
f.parts[key] = append(parts, part)
return nil
}
func (f *fakeMediaStore) UploadPartUsage(_ context.Context, ownerUserID int64) (domain.UploadPartUsage, error) {
f.mu.Lock()
defer f.mu.Unlock()
var usage domain.UploadPartUsage
files := map[int64]struct{}{}
for _, parts := range f.parts {
for _, p := range parts {
if p.OwnerUserID != ownerUserID {
continue
}
usage.Bytes += p.Size
usage.Parts++
files[p.FileID] = struct{}{}
}
}
usage.Files = len(files)
return usage, nil
}
func (f *fakeMediaStore) UploadPartSlot(_ context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) {
f.mu.Lock()
defer f.mu.Unlock()
parts := f.parts[fakeUploadPartKey(ownerUserID, fileID)]
slot := domain.UploadPartSlot{FileParts: len(parts)}
for _, p := range parts {
if p.Part == part {
slot.ExistingBytes = p.Size
slot.ObjectKey = p.ObjectKey
slot.Found = true
break
}
}
return slot, nil
}
func (f *fakeMediaStore) LoadFileParts(_ context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) {
f.mu.Lock()
defer f.mu.Unlock()
parts := append([]domain.UploadPart(nil), f.parts[fakeUploadPartKey(ownerUserID, fileID)]...)
sort.Slice(parts, func(i, j int) bool { return parts[i].Part < parts[j].Part })
return parts, nil
}
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, ownerUserID, fileID int64) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := fakeUploadPartKey(ownerUserID, fileID)
parts := f.parts[key]
keys := make([]string, 0, len(parts))
for _, p := range parts {
keys = append(keys, p.ObjectKey)
}
delete(f.parts, key)
return keys, nil
}
func (f *fakeMediaStore) DeleteExpiredUploadParts(_ context.Context, _ time.Time, _ int) ([]string, error) {
return nil, nil
}
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil }
func fakeUploadPartKey(ownerUserID, fileID int64) string {
return fmt.Sprintf("%d:%d", ownerUserID, fileID)
}
func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error {
f.mu.Lock()
@ -50,6 +126,32 @@ func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.File
return b, ok, nil
}
func (f *fakeMediaStore) GetFileBlobs(_ context.Context, keys []string) (map[string]domain.FileBlob, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make(map[string]domain.FileBlob, len(keys))
for _, key := range keys {
if b, ok := f.blobs[key]; ok {
out[key] = b
}
}
return out, nil
}
func (f *fakeMediaStore) GetSeedState(_ context.Context, key string) (string, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
hash, ok := f.seedState[key]
return hash, ok, nil
}
func (f *fakeMediaStore) PutSeedState(_ context.Context, key, hash string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.seedState[key] = hash
return nil
}
func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error {
f.mu.Lock()
defer f.mu.Unlock()
@ -85,6 +187,21 @@ func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bo
p, ok := f.photos[id]
return p, ok, nil
}
func (f *fakeMediaStore) PutWebPage(_ context.Context, urlHash int64, page domain.MessageWebPage, _ int) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.webPages == nil {
f.webPages = map[int64]domain.MessageWebPage{}
}
f.webPages[urlHash] = page
return nil
}
func (f *fakeMediaStore) GetWebPageByURLHash(_ context.Context, urlHash int64) (domain.MessageWebPage, int, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
p, ok := f.webPages[urlHash]
return p, 0, ok, nil
}
func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error {
f.mu.Lock()
@ -156,15 +273,9 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
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) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
return nil
}
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
return 0, false, nil
}
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) {
return 0, false, nil
}
@ -174,10 +285,10 @@ func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerTy
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (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) {
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
return nil, 0, nil
}
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]domain.Photo, int, error) {
return nil, 0, nil
}
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
@ -252,6 +363,146 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
}
}
func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 4444444
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17)
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(ctx, seedDir, 0)
if err != nil {
t.Fatalf("seed media: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("stats = %+v, want one set, one doc, main blob plus synthetic preview", stats)
}
set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack")
if err != nil || !ok {
t.Fatalf("StatusPack ok=%v err=%v", ok, err)
}
doc, ok, err := media.GetDocument(ctx, set.DocumentIDs[0])
if err != nil || !ok {
t.Fatalf("StatusPack document ok=%v err=%v", ok, err)
}
if !seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji) {
t.Fatalf("document attributes = %+v, want custom emoji", doc.Attributes)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok {
t.Fatalf("document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
}
if thumb.Type != seedSyntheticDocumentThumbType || thumb.W != 1 || thumb.H != 1 || len(thumb.Bytes) == 0 {
t.Fatalf("synthetic thumb = %+v, want 1x1 cached %q thumb", thumb, seedSyntheticDocumentThumbType)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
if err != nil || !ok {
t.Fatalf("synthetic thumb blob ok=%v err=%v", ok, err)
}
if blob.MimeType != "image/png" {
t.Fatalf("synthetic thumb blob mime = %q, want image/png", blob.MimeType)
}
}
func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 5555555
const setHash = 23
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, setHash)
media := newFakeMediaStore()
if err := media.PutDocument(ctx, domain.Document{
ID: sourceID,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrCustomEmoji,
Alt: "\U0001f44b",
TextColor: true,
}},
}); err != nil {
t.Fatalf("put stale document: %v", err)
}
if err := media.PutStickerSet(ctx, domain.StickerSet{
ID: 773947703670341676,
AccessHash: 1,
ShortName: "StatusPack",
Title: "Status Pack",
Hash: setHash,
Kind: domain.StickerSetKindEmoji,
Emojis: true,
DocumentIDs: []int64{
sourceID,
},
}); err != nil {
t.Fatalf("put stale sticker set: %v", err)
}
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
stats, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("repair seed: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("repair stats = %+v, want forced reimport", stats)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("repaired document ok=%v err=%v", ok, err)
}
if _, ok := findCachedThumb(doc.Thumbs); !ok {
t.Fatalf("repaired document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
}
}
func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 6666666
writeEffectsSeed(t, seedDir, sourceID)
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
first, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("first seed: %v", err)
}
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 {
t.Fatalf("first stats = %+v, want one imported effect document/blob", first)
}
second, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("second seed: %v", err)
}
if second.Effects != 1 || second.Documents != 0 || second.Blobs != 0 {
t.Fatalf("second stats = %+v, want effects catalog loaded without document/blob import", second)
}
delete(media.blobs, fmt.Sprintf("doc:%d", sourceID))
repaired, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("repair seed: %v", err)
}
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 {
t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired)
}
}
func TestSeedMediaFromRealExport(t *testing.T) {
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
if seedDir == "" {
@ -355,6 +606,37 @@ func TestSeedMediaFromRealExport(t *testing.T) {
}
}
func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) {
t.Helper()
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
t.Fatal(err)
}
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[]}]}}`, setHash, sourceID, sourceID, sourceID)
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) {
t.Helper()
docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
t.Fatal(err)
}
raw := fmt.Sprintf(`{"result":{"effects":[{"id":77,"emoticon":"🔥","effect_sticker_id":%d}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeFilename","file_name":"effect.tgs"}],"thumbs":[]}]}}`, sourceID, sourceID)
if err := os.WriteFile(filepath.Join(seedDir, "telegram_effects_export", "effects.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(docsDir, fmt.Sprintf("effect_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
const sourceID int64 = 5382305375846410902
const want int64 = 1382305375846410902

View file

@ -1,11 +1,19 @@
package files
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"hash"
"io"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。
@ -14,6 +22,15 @@ const (
MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体
)
const (
DefaultUploadPartTTL = 24 * time.Hour
DefaultUploadPartGCInterval = 30 * time.Minute
DefaultUploadPartGCBatch = 10000
DefaultUploadInFlightMaxBytes = int64(MaxUploadPartBytes) * int64(MaxUploadParts)
DefaultUploadInFlightMaxParts = MaxUploadParts
DefaultUploadInFlightMaxFiles = 64
)
// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。
const blobMetaCacheCapacity = 1 << 16
@ -21,28 +38,104 @@ const blobMetaCacheCapacity = 1 << 16
const (
blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB
blobBytesCacheMaxBytes = 64 << 20 // 64MB
// stickerSetNegativeCacheTTL 是未找到贴纸集的负缓存有效期:未 seed 的 short_name 会被客户端
// 反复 getStickerSet,这里短时缓存 not-found 短路掉 PG。短 TTL 保证运行时新增集合最多滞后这么久。
stickerSetNegativeCacheTTL = 30 * time.Second
)
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
type Service struct {
media store.MediaStore
blobs BlobBackend
dc int
blobCache *blobMetaCache
byteCache *blobBytesCache
stickerSetCache *stickerSetFullCache
media store.MediaStore
blobs BlobBackend
uploadParts UploadPartBackend
dc int
log *zap.Logger
thumbs VideoThumbnailer
thumbsSet bool
blobCache *blobMetaCache
byteCache *blobBytesCache
// blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打
// 一发 PG GetFileBlob + backend GetRange(热门贴纸/reaction/头像被大量用户同时拉时尤甚)。
blobMetaSF singleflight.Group
blobBytesSF singleflight.Group
stickerSetCache *stickerSetFullCache
stickerSetNegCache *stickerSetNegativeCache
uploadQuota domain.UploadPartQuota
mapTiles *mapTileProxy
externalMedia *externalMediaFetcher
webpage *webpageFetcher
// effects 是消息发送特效目录(messages.getAvailableEffects)。全局静态,启动 seedEffects
// 一次写入后只读,故无锁——与各 read-model 缓存一样在服务就绪前完成填充。
// effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。
effects []domain.AvailableEffect
effectsHash int
}
// Option 配置 files 服务的可选能力。
type Option func(*Service)
// WithLogger 注入日志器。未注入时使用 no-op logger。
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// WithVideoThumbnailer 覆盖视频缩略图生成器。传 nil 可显式关闭服务端抽帧 fallback。
func WithVideoThumbnailer(thumbnailer VideoThumbnailer) Option {
return func(s *Service) {
s.thumbs = thumbnailer
s.thumbsSet = true
}
}
// WithUploadPartQuota 覆盖用户级 in-flight 上传分片配额;字段 <=0 表示该维度不限制。
func WithUploadPartQuota(quota domain.UploadPartQuota) Option {
return func(s *Service) {
s.uploadQuota = quota
}
}
// 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(),
func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service {
s := &Service{
media: media,
blobs: blobs,
dc: dc,
log: zap.NewNop(),
blobCache: newBlobMetaCache(blobMetaCacheCapacity),
byteCache: newBlobBytesCache(blobBytesCacheMaxBytes),
stickerSetCache: newStickerSetFullCache(),
stickerSetNegCache: newStickerSetNegativeCache(stickerSetNegativeCacheTTL),
uploadQuota: domain.UploadPartQuota{
MaxBytes: DefaultUploadInFlightMaxBytes,
MaxParts: DefaultUploadInFlightMaxParts,
MaxFiles: DefaultUploadInFlightMaxFiles,
},
}
if partBackend, ok := blobs.(UploadPartBackend); ok {
s.uploadParts = partBackend
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.mapTiles != nil {
// 选项应用顺序无关:logger 在全部 Option 跑完后统一注入。
s.mapTiles.log = s.log
}
if !s.thumbsSet {
thumbnailer, err := NewFFmpegVideoThumbnailer()
if err != nil {
s.log.Warn("ffmpeg not found; server-side video thumbnail fallback disabled", zap.Error(err))
} else {
s.thumbs = thumbnailer
}
}
return s
}
// SaveFilePart 累积一个 small file 分片。
@ -50,12 +143,12 @@ func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, p
if err := validatePart(part, len(bytes)); err != nil {
return false, err
}
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
if err := s.saveFilePart(ctx, domain.UploadPart{
OwnerUserID: ownerUserID,
FileID: fileID,
Part: part,
Bytes: bytes,
}); err != nil {
Size: int64(len(bytes)),
}, bytes); err != nil {
return false, err
}
return true, nil
@ -69,37 +162,142 @@ func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64
if totalParts <= 0 || totalParts > MaxUploadParts {
return false, domain.ErrFilePartsInvalid
}
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
if err := s.saveFilePart(ctx, domain.UploadPart{
OwnerUserID: ownerUserID,
FileID: fileID,
Part: part,
TotalParts: totalParts,
Big: true,
Bytes: bytes,
}); err != nil {
Size: int64(len(bytes)),
}, bytes); err != nil {
return false, err
}
return true, nil
}
func (s *Service) saveFilePart(ctx context.Context, part domain.UploadPart, bytes []byte) error {
if s.uploadParts == nil {
return fmt.Errorf("upload part backend not configured")
}
slot, err := s.checkUploadPartQuota(ctx, part)
if err != nil {
return err
}
obj, err := s.uploadParts.PutUploadPart(ctx, part.OwnerUserID, part.FileID, part.Part, bytes)
if err != nil {
return err
}
part.Backend = obj.Backend
part.ObjectKey = obj.ObjectKey
part.Size = obj.Size
part.SHA256 = obj.SHA256
if err := s.media.SaveFilePart(ctx, part); err != nil {
_ = s.uploadParts.DeleteUploadPart(ctx, obj.ObjectKey)
return err
}
if slot.Found && slot.ObjectKey != "" && slot.ObjectKey != obj.ObjectKey {
if err := s.uploadParts.DeleteUploadPart(ctx, slot.ObjectKey); err != nil {
s.log.Warn("delete replaced upload part failed", zap.String("object_key", slot.ObjectKey), zap.Error(err))
}
}
return nil
}
func (s *Service) checkUploadPartQuota(ctx context.Context, part domain.UploadPart) (domain.UploadPartSlot, error) {
slot, err := s.media.UploadPartSlot(ctx, part.OwnerUserID, part.FileID, part.Part)
if err != nil {
return domain.UploadPartSlot{}, err
}
quota := s.uploadQuota
if quota.MaxBytes <= 0 && quota.MaxParts <= 0 && quota.MaxFiles <= 0 {
return slot, nil
}
usage, err := s.media.UploadPartUsage(ctx, part.OwnerUserID)
if err != nil {
return domain.UploadPartSlot{}, err
}
next := usage
next.Bytes += part.Size - slot.ExistingBytes
if !slot.Found {
next.Parts++
}
if slot.FileParts == 0 {
next.Files++
}
if quota.MaxBytes > 0 && next.Bytes > quota.MaxBytes {
return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded
}
if quota.MaxParts > 0 && next.Parts > quota.MaxParts {
return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded
}
if quota.MaxFiles > 0 && next.Files > quota.MaxFiles {
return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded
}
return slot, nil
}
// DeleteExpiredUploadParts 清理超过保留期仍未组装的 transient 上传分片。
func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) {
if limit <= 0 {
return 0, nil
}
keys, err := s.media.DeleteExpiredUploadParts(ctx, before, limit)
if err != nil {
return 0, err
}
if err := s.deleteUploadPartObjects(ctx, keys); err != nil {
return int64(len(keys)), err
}
var orphanDeleted int64
if s.uploadParts != nil {
n, err := s.uploadParts.DeleteExpiredUploadParts(ctx, before, limit)
if err != nil {
return int64(len(keys)), err
}
orphanDeleted = n
}
return int64(len(keys)) + orphanDeleted, nil
}
// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
type blobMetaResult struct {
blob domain.FileBlob
found bool
}
type blobBytesResult struct {
data []byte
total int64
cacheable bool
}
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)
// 同一 location_key 的并发首访合并成一次 PG GetFileBlob。
v, err, _ := s.blobMetaSF.Do(req.LocationKey, func() (any, error) {
if cached, ok := s.blobCache.get(req.LocationKey); ok {
return blobMetaResult{blob: cached, found: true}, nil
}
b, found, err := s.media.GetFileBlob(ctx, req.LocationKey)
if err != nil {
return blobMetaResult{}, err
}
if found {
s.blobCache.put(req.LocationKey, b)
}
return blobMetaResult{blob: b, found: found}, nil
})
if err != nil {
return domain.FileChunk{}, false, err
}
if !found {
res := v.(blobMetaResult)
if !res.found {
return domain.FileChunk{}, false, nil
}
s.blobCache.put(req.LocationKey, blob)
blob = res.blob
}
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
@ -109,18 +307,33 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
Total: int64(len(data)),
}, true, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
// 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。
v, err, _ := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) {
if cached, ok := s.byteCache.get(blob.ObjectKey); ok {
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true}, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
if err != nil {
return blobBytesResult{}, err
}
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
s.byteCache.put(blob.ObjectKey, data)
return blobBytesResult{data: data, total: total, cacheable: true}, nil
}
return blobBytesResult{cacheable: false}, nil
})
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)
// res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。
if res := v.(blobBytesResult); res.cacheable {
return domain.FileChunk{
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)),
MimeType: blob.MimeType,
Total: total,
Total: res.total,
}, true, nil
}
// 大小不符/超限:落到下面的按需 range 读(与原行为一致)。
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
if err != nil {
@ -170,6 +383,11 @@ func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRe
if set, docs, ok := s.stickerSetCache.get(ref); ok {
return set, docs, true, nil
}
// 负缓存:已 seed 集启动即进正缓存(WarmCaches),能走到这里的 miss 多是「未 seed 的 short_name」
// 被客户端反复请求。TTL 内直接当 not-found 短路,避免每次都打 PG GetStickerSetByShortName。
if s.stickerSetNegCache != nil && s.stickerSetNegCache.has(ref) {
return domain.StickerSet{}, nil, false, nil
}
var (
set domain.StickerSet
found bool
@ -186,6 +404,9 @@ func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRe
return domain.StickerSet{}, nil, false, nil
}
if err != nil || !found {
if err == nil && !found && s.stickerSetNegCache != nil {
s.stickerSetNegCache.put(ref)
}
return domain.StickerSet{}, nil, found, err
}
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
@ -215,33 +436,226 @@ func orderDocuments(docs []domain.Document, ids []int64) []domain.Document {
// 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)
parts, _, err := s.loadAndValidateUploadParts(ctx, ownerUserID, fileID, expectedParts)
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)
buf := make([]byte, 0, uploadPartsTotalSize(parts))
for _, p := range parts {
buf = append(buf, p.Bytes...)
if s.uploadParts == nil {
return nil, fmt.Errorf("upload part backend not configured")
}
data, err := s.uploadParts.GetUploadPart(ctx, p.ObjectKey)
if err != nil {
return nil, fmt.Errorf("read upload part %d: %w", p.Part, err)
}
if err := validateUploadPartBytes(p, data); err != nil {
return nil, err
}
buf = append(buf, data...)
}
if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil {
if err := s.cleanupUploadParts(ctx, ownerUserID, fileID); err != nil {
return nil, err
}
return buf, nil
}
type assembledUploadBlob struct {
ObjectKey string
Size int64
SHA256 []byte
}
// assembleUploadBlob 把上传分片流式写入正式 blob。调用方应在 durable media 元数据
// 成功提交后调用 cleanupUploadParts,避免 metadata 写失败时丢失可重试的上传分片。
func (s *Service) assembleUploadBlob(ctx context.Context, ownerUserID, fileID int64, expectedParts int) (assembledUploadBlob, error) {
parts, _, err := s.loadAndValidateUploadParts(ctx, ownerUserID, fileID, expectedParts)
if err != nil {
return assembledUploadBlob{}, err
}
if s.uploadParts == nil {
return assembledUploadBlob{}, fmt.Errorf("upload part backend not configured")
}
reader := &uploadPartsReader{
ctx: ctx,
backend: s.uploadParts,
parts: parts,
}
defer reader.Close()
objectKey, size, sum, err := s.blobs.PutReader(ctx, reader)
if err != nil {
return assembledUploadBlob{}, err
}
return assembledUploadBlob{
ObjectKey: objectKey,
Size: size,
SHA256: sum,
}, nil
}
func (s *Service) loadAndValidateUploadParts(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]domain.UploadPart, int64, error) {
parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID)
if err != nil {
return nil, 0, err
}
if len(parts) == 0 {
return nil, 0, domain.ErrFilePartsInvalid
}
if expectedParts > 0 && len(parts) != expectedParts {
return nil, 0, domain.ErrFilePartsInvalid
}
var total int64
for i, p := range parts {
if p.Part != i {
return nil, 0, domain.ErrFilePartsInvalid // 缺片或乱序
}
if p.Size <= 0 || p.Size > MaxUploadPartBytes || p.ObjectKey == "" {
return nil, 0, domain.ErrFilePartsInvalid
}
total += p.Size
if total > DefaultUploadInFlightMaxBytes {
return nil, 0, domain.ErrFilePartsInvalid
}
}
return parts, total, nil
}
func uploadPartsTotalSize(parts []domain.UploadPart) int {
var total int64
for _, p := range parts {
total += p.Size
}
return int(total)
}
func validateUploadPartBytes(part domain.UploadPart, data []byte) error {
if int64(len(data)) != part.Size {
return domain.ErrFilePartsInvalid
}
if len(part.SHA256) > 0 {
sum := sha256.Sum256(data)
if !bytes.Equal(sum[:], part.SHA256) {
return domain.ErrFilePartsInvalid
}
}
return nil
}
func (s *Service) cleanupUploadParts(ctx context.Context, ownerUserID, fileID int64) error {
keys, err := s.media.DeleteFileParts(ctx, ownerUserID, fileID)
if err != nil {
return err
}
if err := s.deleteUploadPartObjects(ctx, keys); err != nil {
return err
}
return nil
}
type uploadPartsReader struct {
ctx context.Context
backend UploadPartBackend
parts []domain.UploadPart
index int
current io.ReadCloser
currentRead int64
currentHash hash.Hash
}
func (r *uploadPartsReader) Read(buf []byte) (int, error) {
for {
if r.current == nil {
if r.index >= len(r.parts) {
return 0, io.EOF
}
select {
case <-r.ctx.Done():
return 0, r.ctx.Err()
default:
}
part := r.parts[r.index]
rc, err := r.backend.OpenUploadPart(r.ctx, part.ObjectKey)
if err != nil {
return 0, fmt.Errorf("open upload part %d: %w", part.Part, err)
}
r.current = rc
r.currentRead = 0
if len(part.SHA256) > 0 {
r.currentHash = sha256.New()
} else {
r.currentHash = nil
}
}
n, err := r.current.Read(buf)
if n > 0 {
r.currentRead += int64(n)
if r.currentHash != nil {
_, _ = r.currentHash.Write(buf[:n])
}
return n, nil
}
if err == io.EOF {
if err := r.finishCurrentPart(); err != nil {
return 0, err
}
continue
}
if err != nil {
_ = r.current.Close()
part := r.parts[r.index]
r.current = nil
return 0, fmt.Errorf("read upload part %d: %w", part.Part, err)
}
return 0, nil
}
}
func (r *uploadPartsReader) finishCurrentPart() error {
part := r.parts[r.index]
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return fmt.Errorf("close upload part %d: %w", part.Part, closeErr)
}
if r.currentRead != part.Size {
return domain.ErrFilePartsInvalid
}
if r.currentHash != nil && !bytes.Equal(r.currentHash.Sum(nil), part.SHA256) {
return domain.ErrFilePartsInvalid
}
r.currentHash = nil
r.currentRead = 0
r.index++
return nil
}
func (r *uploadPartsReader) Close() error {
if r.current == nil {
return nil
}
err := r.current.Close()
r.current = nil
return err
}
func (s *Service) deleteUploadPartObjects(ctx context.Context, keys []string) error {
if len(keys) == 0 {
return nil
}
if s.uploadParts == nil {
return fmt.Errorf("upload part backend not configured")
}
for _, key := range keys {
if key == "" {
continue
}
if err := s.uploadParts.DeleteUploadPart(ctx, key); err != nil {
return err
}
}
return nil
}
func validatePart(part, size int) error {
if part < 0 || part >= MaxUploadParts {
return domain.ErrFilePartInvalid

View file

@ -0,0 +1,97 @@
package files
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与
// blob,不复制字节),镜像 EnsureDefaultEmojiStatusSet。目录是静态的,不入库。
// 礼物 ID 取明显隔离的常量段避免撞键。
const starGiftIDBase int64 = 8_888_000_000_000_000
type starGiftSeed struct {
id int64
emoticon string
stars int64
title string
}
// starGiftSeeds 是固定礼物目录(emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。
// convert_stars = stars(v1 全额转换,视作用新购 Stars 买入)。
var starGiftSeeds = []starGiftSeed{
{starGiftIDBase + 1, "❤", 15, "Heart"},
{starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂
{starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉
{starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥
{starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆
{starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎
{starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀
}
// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。
// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。
func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) {
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
if err != nil {
return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err)
}
if !found || len(source.Packs) == 0 {
return nil, nil
}
byEmoticon := make(map[string]int64, len(source.Packs))
for _, pack := range source.Packs {
key := normalizeStatusEmoticon(pack.Emoticon)
if key == "" || len(pack.DocumentIDs) == 0 {
continue
}
if _, ok := byEmoticon[key]; !ok {
byEmoticon[key] = pack.DocumentIDs[0]
}
}
// 收集要加载的文档 id(去重)。
docIDs := make([]int64, 0, len(starGiftSeeds))
chosen := make([]starGiftSeed, 0, len(starGiftSeeds))
seen := make(map[int64]struct{})
for _, seed := range starGiftSeeds {
id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
if !ok || id == 0 {
continue
}
chosen = append(chosen, seed)
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
docIDs = append(docIDs, id)
}
}
if len(chosen) == 0 {
return nil, nil
}
docs, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
return nil, fmt.Errorf("load star gift sticker documents: %w", err)
}
docByID := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
docByID[d.ID] = d
}
catalog := make([]domain.StarGift, 0, len(chosen))
for _, seed := range chosen {
id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
doc, ok := docByID[id]
if !ok || doc.ID == 0 {
continue
}
catalog = append(catalog, domain.StarGift{
ID: seed.id,
Stars: seed.stars,
ConvertStars: seed.stars,
Title: seed.title,
Sticker: doc,
})
}
return catalog, nil
}

View file

@ -0,0 +1,67 @@
package files
import (
"context"
"time"
"go.uber.org/zap"
)
// UploadPartGCWorker 周期性清理未组装的过期上传分片。
type UploadPartGCWorker struct {
files *Service
logger *zap.Logger
ttl time.Duration
interval time.Duration
batch int
}
func NewUploadPartGCWorker(files *Service, logger *zap.Logger, ttl, interval time.Duration, batch int) *UploadPartGCWorker {
if logger == nil {
logger = zap.NewNop()
}
if ttl <= 0 {
ttl = DefaultUploadPartTTL
}
if interval <= 0 {
interval = DefaultUploadPartGCInterval
}
if batch <= 0 {
batch = DefaultUploadPartGCBatch
}
return &UploadPartGCWorker{
files: files,
logger: logger,
ttl: ttl,
interval: interval,
batch: batch,
}
}
func (w *UploadPartGCWorker) 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 *UploadPartGCWorker) runOnce(ctx context.Context) {
if w.files == nil {
return
}
deleted, err := w.files.DeleteExpiredUploadParts(ctx, time.Now().Add(-w.ttl), w.batch)
if err != nil {
w.logger.Warn("清理过期 upload_parts 失败", zap.Error(err))
return
}
if deleted > 0 {
w.logger.Info("清理过期 upload_parts 完成", zap.Int64("deleted", deleted))
}
}

View file

@ -0,0 +1,149 @@
package files
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"telesrv/internal/domain"
)
func TestSaveFilePartQuotaTreatsRetryAsOverwrite(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
svc, blobs := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 4, MaxParts: 1, MaxFiles: 1})
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil {
t.Fatalf("save first part: %v", err)
}
firstParts, err := media.LoadFileParts(ctx, 10, 100)
if err != nil || len(firstParts) != 1 || firstParts[0].ObjectKey == "" {
t.Fatalf("load first part metadata: parts=%+v err=%v", firstParts, err)
}
firstKey := firstParts[0].ObjectKey
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil {
t.Fatalf("retry same part should overwrite without extra quota: %v", err)
}
parts, err := media.LoadFileParts(ctx, 10, 100)
if err != nil {
t.Fatalf("load parts: %v", err)
}
if len(parts) != 1 || parts[0].Size != 4 || parts[0].ObjectKey == "" || parts[0].ObjectKey == firstKey {
t.Fatalf("parts after retry = %+v", parts)
}
if _, err := blobs.GetUploadPart(ctx, firstKey); err == nil {
t.Fatalf("replaced upload part object %q still exists", firstKey)
}
data, err := svc.assembleUpload(ctx, 10, 100, 1)
if err != nil {
t.Fatalf("assemble upload: %v", err)
}
if string(data) != "1234" {
t.Fatalf("assembled data = %q", data)
}
}
func TestSaveFilePartQuotaRejectsNewFileOverLimit(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
svc, _ := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 8, MaxParts: 4, MaxFiles: 1})
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil {
t.Fatalf("save first file part: %v", err)
}
_, err := svc.SaveFilePart(ctx, 10, 101, 0, []byte("12"))
if !errors.Is(err, domain.ErrUploadQuotaExceeded) {
t.Fatalf("save second file err = %v, want ErrUploadQuotaExceeded", err)
}
}
func TestSaveFilePartQuotaRejectsPartAndByteOverLimit(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
svc, _ := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 5, MaxParts: 1, MaxFiles: 2})
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil {
t.Fatalf("save first part: %v", err)
}
_, err := svc.SaveFilePart(ctx, 10, 100, 1, []byte("12"))
if !errors.Is(err, domain.ErrUploadQuotaExceeded) {
t.Fatalf("save second part err = %v, want ErrUploadQuotaExceeded", err)
}
_, err = svc.SaveFilePart(ctx, 10, 100, 0, []byte("123456"))
if !errors.Is(err, domain.ErrUploadQuotaExceeded) {
t.Fatalf("grow retried part err = %v, want ErrUploadQuotaExceeded", err)
}
}
func TestCreateDocumentFromUploadStreamsBodyAndCleansParts(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
local, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
blobs := &countingUploadPartBackend{LocalFS: local}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil))
parts := []string{
strings.Repeat("a", 1024),
strings.Repeat("b", 1024),
strings.Repeat("c", 1024),
}
for i, part := range parts {
if _, err := svc.SaveBigFilePart(ctx, 10, 200, i, len(parts), []byte(part)); err != nil {
t.Fatalf("SaveBigFilePart %d: %v", i, err)
}
}
doc, err := svc.CreateDocumentFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 200, Parts: len(parts), Name: "large.bin", Big: true},
domain.DocumentSpec{MimeType: "application/octet-stream"},
)
if err != nil {
t.Fatalf("CreateDocumentFromUpload: %v", err)
}
if doc.Size != int64(len(parts[0])+len(parts[1])+len(parts[2])) {
t.Fatalf("doc size = %d", doc.Size)
}
if blobs.getUploadPartCalls != 0 {
t.Fatalf("streaming document path called GetUploadPart %d times", blobs.getUploadPartCalls)
}
if remaining, err := media.LoadFileParts(ctx, 10, 200); err != nil || len(remaining) != 0 {
t.Fatalf("upload parts after success = %+v err=%v", remaining, err)
}
blob, ok, err := media.GetFileBlob(ctx, "doc:"+strconv.FormatInt(doc.ID, 10))
if err != nil || !ok {
t.Fatalf("body file blob ok=%v err=%v", ok, err)
}
body, err := local.Get(ctx, blob.ObjectKey)
if err != nil {
t.Fatalf("read body blob: %v", err)
}
if string(body) != strings.Join(parts, "") {
t.Fatalf("body blob mismatch")
}
}
type countingUploadPartBackend struct {
*LocalFS
getUploadPartCalls int
}
func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) {
c.getUploadPartCalls++
return c.LocalFS.GetUploadPart(ctx, objectKey)
}
func newUploadPartTestService(t *testing.T, media *fakeMediaStore, quota domain.UploadPartQuota) (*Service, *LocalFS) {
t.Helper()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
return NewService(media, blobs, 2,
WithVideoThumbnailer(nil),
WithUploadPartQuota(quota),
), blobs
}

View file

@ -0,0 +1,132 @@
package files
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
const (
videoThumbnailTimeout = 5 * time.Second
videoThumbnailMaxInputBytes = 200 << 20 // 200MB;更大文件本阶段跳过 fallback,避免阻塞发送。
videoThumbnailMaxConcurrent = 2
)
// VideoThumbnailer 从视频字节中抽取静态缩略图。实现必须可失败降级,不影响原发送流程。
type VideoThumbnailer interface {
Extract(ctx context.Context, data []byte, mimeType string) ([]byte, error)
}
// FFmpegVideoThumbnailer 使用本机 ffmpeg 抽取第一帧 JPEG。
type FFmpegVideoThumbnailer struct {
path string
timeout time.Duration
slots chan struct{}
}
// NewFFmpegVideoThumbnailer 返回基于 PATH 中 ffmpeg 的抽帧器。
func NewFFmpegVideoThumbnailer() (*FFmpegVideoThumbnailer, error) {
path, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, err
}
return &FFmpegVideoThumbnailer{
path: path,
timeout: videoThumbnailTimeout,
slots: make(chan struct{}, videoThumbnailMaxConcurrent),
}, nil
}
// Extract 抽取第一帧并输出 JPEG bytes。
func (t *FFmpegVideoThumbnailer) Extract(ctx context.Context, data []byte, mimeType string) ([]byte, error) {
if t == nil || t.path == "" {
return nil, fmt.Errorf("ffmpeg thumbnailer unavailable")
}
if len(data) == 0 {
return nil, fmt.Errorf("empty video data")
}
if len(data) > videoThumbnailMaxInputBytes {
return nil, fmt.Errorf("video too large for thumbnail fallback: %d bytes", len(data))
}
select {
case t.slots <- struct{}{}:
defer func() { <-t.slots }()
case <-ctx.Done():
return nil, ctx.Err()
}
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
input, err := os.CreateTemp("", "telesrv-video-*"+videoTempExt(mimeType))
if err != nil {
return nil, fmt.Errorf("create temp video: %w", err)
}
inputPath := input.Name()
defer os.Remove(inputPath)
if _, err := input.Write(data); err != nil {
input.Close()
return nil, fmt.Errorf("write temp video: %w", err)
}
if err := input.Close(); err != nil {
return nil, fmt.Errorf("close temp video: %w", err)
}
output, err := os.CreateTemp("", "telesrv-video-thumb-*.jpg")
if err != nil {
return nil, fmt.Errorf("create temp thumbnail: %w", err)
}
outputPath := output.Name()
output.Close()
defer os.Remove(outputPath)
cmd := exec.CommandContext(
runCtx,
t.path,
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", inputPath,
"-map", "0:v:0",
"-frames:v", "1",
"-an",
"-vf", "scale=320:320:force_original_aspect_ratio=decrease",
"-q:v", "3",
outputPath,
)
stderr, err := cmd.CombinedOutput()
if runCtx.Err() != nil {
return nil, runCtx.Err()
}
if err != nil {
msg := strings.TrimSpace(string(stderr))
if msg != "" {
return nil, fmt.Errorf("ffmpeg extract thumbnail: %w: %s", err, msg)
}
return nil, fmt.Errorf("ffmpeg extract thumbnail: %w", err)
}
thumb, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("read thumbnail: %w", err)
}
if len(thumb) == 0 {
return nil, fmt.Errorf("ffmpeg produced empty thumbnail")
}
return thumb, nil
}
func videoTempExt(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "video/mp4":
return ".mp4"
case "video/quicktime":
return ".mov"
case "video/webm":
return ".webm"
default:
return ".bin"
}
}

View file

@ -18,7 +18,19 @@ type WarmStats struct {
// SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。
func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) {
var stats WarmStats
// 第一阶段:收集所有待预热文档(贴纸集 + reaction),按 doc ID 去重。
seenDocs := make(map[int64]struct{})
docs := make([]domain.Document, 0, 256)
collect := func(doc domain.Document) {
if doc.ID == 0 {
return
}
if _, ok := seenDocs[doc.ID]; ok {
return
}
seenDocs[doc.ID] = struct{}{}
docs = append(docs, doc)
}
for _, kind := range []domain.StickerSetKind{
domain.StickerSetKindStickers,
domain.StickerSetKindEmoji,
@ -30,24 +42,15 @@ func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) {
return stats, err
}
for _, set := range sets {
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
setDocs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
if err != nil {
return stats, err
}
ordered := orderDocuments(docs, set.DocumentIDs)
ordered := orderDocuments(setDocs, 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
collect(doc)
}
}
}
@ -59,68 +62,62 @@ func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) {
for _, reaction := range reactions {
reactionIDs = append(reactionIDs, reaction.DocumentIDs()...)
}
docs, err := s.media.GetDocuments(ctx, reactionIDs)
reactionDocs, err := s.media.GetDocuments(ctx, reactionIDs)
if err != nil {
return stats, err
}
for _, doc := range reactionDocs {
collect(doc)
}
stats.Documents = len(docs)
// 第二阶段:一发 ANY 查询批量取所有 location key 的 blob 元数据,替代过去逐个
// GetFileBlob 的启动期 N+1(~2400 个 blob 各打一次 PG → 一次往返)。
keys := make([]string, 0, len(docs)*2)
for _, doc := range docs {
if _, ok := seenDocs[doc.ID]; ok {
keys = append(keys, blobLocationKeys(doc)...)
}
blobs, err := s.media.GetFileBlobs(ctx, keys)
if err != nil {
return stats, err
}
// 第三阶段:填充元数据缓存,并把小 blob 的全量字节读入字节缓存(blob backend 读,非 PG)。
for _, key := range keys {
blob, ok := blobs[key]
if !ok {
continue
}
seenDocs[doc.ID] = struct{}{}
stats.Documents++
warmed, err := s.prewarmDocumentBlobs(ctx, doc)
s.blobCache.put(key, blob)
warmed, err := s.warmBlobBytes(ctx, blob)
if err != nil {
return stats, err
}
stats.Blobs += warmed
if warmed {
stats.Blobs++
}
}
return stats, nil
}
func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) {
// blobLocationKeys 返回一个文档需预热的全部 location key(主体 + 可下载缩略图)。
func blobLocationKeys(doc domain.Document) []string {
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++
return nil
}
keys := make([]string, 0, 1+len(doc.Thumbs))
keys = append(keys, fmt.Sprintf("doc:%d", doc.ID))
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++
}
keys = append(keys, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
}
return warmed, nil
return keys
}
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)
}
// warmBlobBytes 把小 blob 的全量字节读入 byteCache(大 blob 跳过,仍由 GetRange 分段读)。
// 返回是否实际写入了字节缓存。
func (s *Service) warmBlobBytes(ctx context.Context, blob domain.FileBlob) (bool, error) {
if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) {
return false, nil
}

View file

@ -0,0 +1,525 @@
package files
import (
"bytes"
"context"
"errors"
"fmt"
"hash/fnv"
"image"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"syscall"
"time"
"go.uber.org/zap"
"golang.org/x/net/html"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
// 链接预览(webpage preview):抓取消息里的 URL,解析 OpenGraph/Twitter-card/<title>+meta
// 元数据,铸造预览卡片(含可选预览图)。安全模型与 external_media 同构(SSRF 拨号期 IP 校验、
// 仅 http/https、重定向/大小/超时上限、全局限速),但用独立的限速器与缓存,避免与外链媒体抓取
// 争用同一预算。HTML 与预览图共用一个总时长预算(父 ctx deadline)。
//
// 解析结果(done / empty)经 L1 进程内缓存(singleflight 折叠并发同 URL 抓取)+ L3 web_pages
// 表(按规范化 URL 哈希跨实例去重)。瞬时失败(网络/限速/SSRF 拦截)返回 error 不缓存,避免一次
// 抖动把热门链接毒成"无预览"。
var (
// ErrWebPagePreviewDisabled 表示未启用链接预览抓取。
ErrWebPagePreviewDisabled = errors.New("web page preview disabled")
// ErrWebPagePreviewInvalid 表示 URL 不合法/被 SSRF 拦截/上游失败/超限。
ErrWebPagePreviewInvalid = errors.New("web page preview invalid")
// errWebPageTerminal 标记「确定性、短期不会变」的失败(SSRF 拦截/4xx/非法 URL)。这类
// 解析为终态空预览并负缓存,避免每次按键/发送重复打 PG+外网;瞬时失败(5xx/超时/限速/
// dial 失败)不带此标记、不缓存、可重试。
errWebPageTerminal = errors.New("web page terminal")
)
// terminalFetchErr 构造一个终态失败错误(会被负缓存)。
func terminalFetchErr(msg string) error {
return fmt.Errorf("%w: %w: %s", ErrWebPagePreviewInvalid, errWebPageTerminal, msg)
}
const (
webpageRequestTimeout = 15 * time.Second
webpageTotalTimeout = 20 * time.Second
webpageMaxRedirects = 5
// DefaultWebPagePreviewMaxBytes 覆盖 HTML 抓取与预览图抓取(head 在页首,足够)。
DefaultWebPagePreviewMaxBytes = int64(5 << 20)
// DefaultWebPagePreviewRatePerMin 是全局每分钟抓取上限;一次解析最多 2 次上游(HTML+图)。
// 60 是单用户口径,多用户实例偏低(输入预览与真实发送共用此预算易互相饿死),上调到 300。
DefaultWebPagePreviewRatePerMin = 300
webpageRateWindow = time.Minute
// maxWebpageImagePixels 是预览图解压炸弹上界(解码前按 DecodeConfig 尺寸拦截)。
maxWebpageImagePixels = int64(25_000_000)
webpageCacheMaxEntries = 4096
webpageCacheTTL = 10 * time.Minute
// webPageRefreshTTL 是已解析卡片的陈旧阈值:L3 命中且超过此龄时后台 stale-while-revalidate
// 刷新(返回的仍是旧卡片,不阻塞)。webPageRefreshConcurrency 限并发刷新 goroutine。
webPageRefreshTTL = 24 * time.Hour
webPageRefreshConcurrency = 8
// webpageUserAgent 用 Telegram 爬虫标识:很多站点只对已知爬虫吐 OG 标签。
webpageUserAgent = "TelegramBot (like TwitterBot)"
acceptHTML = "text/html,application/xhtml+xml"
acceptImage = "image/*"
)
type webpageFetcher struct {
client *http.Client
maxBytes int64
rateLimit int
cache *readmodelcache.Cache[int64, domain.MessageWebPage]
refreshSem chan struct{}
mu sync.Mutex
fetchTimes []time.Time
}
// WithWebPagePreview 启用链接预览抓取。maxBytes<=0 / ratePerMin<=0 用默认。SSRF 防护恒开。
func WithWebPagePreview(maxBytes int64, ratePerMin int) Option {
return func(s *Service) {
if maxBytes <= 0 {
maxBytes = DefaultWebPagePreviewMaxBytes
}
if ratePerMin <= 0 {
ratePerMin = DefaultWebPagePreviewRatePerMin
}
s.webpage = newWebpageFetcher(maxBytes, ratePerMin, false)
}
}
// newWebpageFetcher 构造抓取器。allowPrivate 仅供测试(指向 httptest loopback);生产恒 false。
func newWebpageFetcher(maxBytes int64, ratePerMin int, allowPrivate bool) *webpageFetcher {
dialer := &net.Dialer{Timeout: webpageRequestTimeout}
dialer.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return ErrWebPagePreviewInvalid
}
ip := net.ParseIP(host)
if ip == nil {
return ErrWebPagePreviewInvalid
}
if !allowPrivate && isBlockedExternalIP(ip) {
// SSRF 拦截是确定性失败 → 标记终态供负缓存(否则每次按键重打 PG+重 dial)。
return terminalFetchErr("blocked address " + host + " (SSRF guard)")
}
return nil
}
client := &http.Client{
Timeout: webpageRequestTimeout,
Transport: &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true, Proxy: nil},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= webpageMaxRedirects {
return fmt.Errorf("%w: too many redirects", ErrWebPagePreviewInvalid)
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("%w: blocked redirect scheme %q", ErrWebPagePreviewInvalid, req.URL.Scheme)
}
return nil
},
}
return &webpageFetcher{
client: client,
maxBytes: maxBytes,
rateLimit: ratePerMin,
refreshSem: make(chan struct{}, webPageRefreshConcurrency),
cache: readmodelcache.New(readmodelcache.Config[int64, domain.MessageWebPage]{
MaxEntries: webpageCacheMaxEntries,
TTL: webpageCacheTTL,
}),
}
}
func (f *webpageFetcher) allowFetch() bool {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
kept := f.fetchTimes[:0]
for _, at := range f.fetchTimes {
if now.Sub(at) <= webpageRateWindow {
kept = append(kept, at)
}
}
f.fetchTimes = kept
if len(f.fetchTimes) >= f.rateLimit {
return false
}
f.fetchTimes = append(f.fetchTimes, now)
return true
}
// fetch 抓取 URL,返回 (字节, content-type)。SSRF 检查在 dial 阶段发生;ctx 承载共享总预算。
func (f *webpageFetcher) fetch(ctx context.Context, rawURL, accept string) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, "", terminalFetchErr("bad url") // 非法 URL 是终态
}
if !f.allowFetch() {
return nil, "", fmt.Errorf("%w: rate limited", ErrWebPagePreviewInvalid) // 限速=瞬时,可重试
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", terminalFetchErr("bad request")
}
req.Header.Set("User-Agent", webpageUserAgent)
req.Header.Set("Accept", accept)
resp, err := f.client.Do(req)
if err != nil {
// SSRF 拦截(dial Control 返回的 terminal)经 url.Error 传上来,errors.Is 仍能识别;
// 其余 dial/超时错误是瞬时。
return nil, "", fmt.Errorf("%w: %v", ErrWebPagePreviewInvalid, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 4xx=确定性(404/403/410…)终态负缓存;5xx=瞬时可重试。
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, "", terminalFetchErr(fmt.Sprintf("upstream status %d", resp.StatusCode))
}
return nil, "", fmt.Errorf("%w: upstream status %d", ErrWebPagePreviewInvalid, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("%w: read body: %v", ErrWebPagePreviewInvalid, err)
}
if len(data) == 0 || int64(len(data)) > f.maxBytes {
return nil, "", fmt.Errorf("%w: body size %d", ErrWebPagePreviewInvalid, len(data))
}
contentType := resp.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
return data, strings.TrimSpace(strings.ToLower(contentType)), nil
}
// WebPagePreviewEnabled 报告链接预览抓取是否启用。
func (s *Service) WebPagePreviewEnabled() bool {
return s != nil && s.webpage != nil
}
// LookupWebPage 仅查缓存(L1 进程内 → L3 web_pages 表)返回已解析的链接预览,不抓取。命中
// 返回 (page,true);未缓存或未启用返回 false。发送路径用它在 echo 直接带 done 卡片。
// 先 Peek L1:客户端输入时 getWebPagePreview 多半已把同一 URL 解析进 L1,发送时即免一次 PG。
func (s *Service) LookupWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) {
if s == nil || s.webpage == nil {
return domain.MessageWebPage{}, false
}
normalized, ok := domain.NormalizeWebPageURL(rawURL)
if !ok {
return domain.MessageWebPage{}, false
}
urlHash := domain.WebPageURLHash(normalized)
if page, ok := s.webpage.cache.Peek(urlHash); ok {
return page, true
}
page, _, found, err := s.media.GetWebPageByURLHash(ctx, urlHash)
if err != nil || !found {
return domain.MessageWebPage{}, false
}
s.webpage.cache.Store(urlHash, page) // 回填 L1,后续 Peek 命中。
return page, true
}
// ResolveWebPage 解析链接预览,经 L1 缓存(singleflight 去重)+ L3 web_pages 持久去重。
// 返回 done / empty 形态的 MessageWebPage;瞬时失败返回 error(调用方降级为空,不报错给用户)。
func (s *Service) ResolveWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, error) {
if s == nil || s.webpage == nil {
return domain.MessageWebPage{}, ErrWebPagePreviewDisabled
}
normalized, ok := domain.NormalizeWebPageURL(rawURL)
if !ok {
return domain.MessageWebPage{}, ErrWebPagePreviewInvalid
}
urlHash := domain.WebPageURLHash(normalized)
return s.webpage.cache.GetOrLoad(ctx, urlHash, func() (domain.MessageWebPage, error) {
// L3 durable 命中:直接复用,跨实例去重;超龄则后台刷新(返回旧卡片不阻塞)。
if page, refreshedAt, found, err := s.media.GetWebPageByURLHash(ctx, urlHash); err == nil && found {
s.webpage.maybeRefresh(s, normalized, urlHash, refreshedAt)
return page, nil
}
// miss:抓取 + 解析(+ 图)。瞬时失败返回 error → GetOrLoad 不缓存(热门链接不被毒化)。
page, err := s.webpage.resolve(ctx, s, normalized, urlHash)
if err != nil {
return domain.MessageWebPage{}, err
}
// 终态(done / empty):落 L3 供跨实例 + 重启复用。
if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil {
s.log.Warn("persist web page preview failed", zap.Int64("url_hash", urlHash), zap.Error(perr))
}
return page, nil
})
}
// maybeRefresh 在卡片超过 webPageRefreshTTL 龄时后台 stale-while-revalidate 刷新一次。
// 并发受 refreshSem 限;满则跳过(下次再刷)。瞬时失败保留旧卡片。
func (f *webpageFetcher) maybeRefresh(s *Service, normalizedURL string, urlHash int64, refreshedAt int) {
if refreshedAt == 0 || time.Now().Unix()-int64(refreshedAt) < int64(webPageRefreshTTL/time.Second) {
return
}
select {
case f.refreshSem <- struct{}{}:
default:
return
}
go func() {
defer func() { <-f.refreshSem }()
ctx, cancel := context.WithTimeout(context.Background(), webpageTotalTimeout)
defer cancel()
page, err := f.resolve(ctx, s, normalizedURL, urlHash)
if err != nil {
return // 瞬时失败:保留旧卡片。
}
if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil {
return
}
f.cache.Store(urlHash, page) // 刷新 L1,使后续读到新卡片。
}()
}
// resolve 实际抓取并构造卡片。HTML 与预览图共享 ctx 总时长预算。
func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL string, urlHash int64) (domain.MessageWebPage, error) {
ctx, cancel := context.WithTimeout(ctx, webpageTotalTimeout)
defer cancel()
data, contentType, err := f.fetch(ctx, normalizedURL, acceptHTML)
if err != nil {
// 终态失败(SSRF/4xx/非法 URL)→ 负缓存为空预览,避免重复按键/发送重打 PG+外网。
// 瞬时失败(5xx/超时/dial/限速)→ 上抛 error,GetOrLoad 不缓存、可重试。
if errors.Is(err, errWebPageTerminal) {
return emptyWebPage(normalizedURL, urlHash), nil
}
return domain.MessageWebPage{}, err
}
if !isHTMLContentType(contentType) {
// 非 HTML(如直接指向图片/二进制):终态空预览。
return emptyWebPage(normalizedURL, urlHash), nil
}
meta := parseWebPageMeta(data, normalizedURL)
if meta.empty() {
return emptyWebPage(normalizedURL, urlHash), nil
}
page := doneWebPage(meta, normalizedURL, urlHash)
if meta.image != "" {
if photo, ok := f.fetchImage(ctx, s, meta.image); ok {
page.Photo = &photo
page.HasLargeMedia = true
}
}
return page, nil
}
// fetchImage 抓取并铸造预览图(best-effort)。解码前按尺寸拦截解压炸弹;非图片/失败丢弃。
func (f *webpageFetcher) fetchImage(ctx context.Context, s *Service, imageURL string) (domain.Photo, bool) {
data, _, err := f.fetch(ctx, imageURL, acceptImage)
if err != nil {
return domain.Photo{}, false
}
cfg, _, derr := image.DecodeConfig(bytes.NewReader(data))
if derr != nil || cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxWebpageImagePixels {
return domain.Photo{}, false
}
photo, err := s.CreatePhotoFromBytes(ctx, data)
if err != nil {
return domain.Photo{}, false
}
return photo, true
}
func emptyWebPage(rawURL string, urlHash int64) domain.MessageWebPage {
return domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: urlHash, URL: rawURL}
}
func doneWebPage(meta webpageMeta, rawURL string, urlHash int64) domain.MessageWebPage {
page := domain.MessageWebPage{
State: domain.MessageWebPageStateDone,
ID: urlHash,
URL: rawURL,
DisplayURL: webpageDisplayURL(rawURL),
Type: meta.pageType(),
SiteName: meta.siteName,
Title: meta.title,
Description: meta.description,
Author: meta.author,
}
page.Hash = webpageContentHash(page)
return page
}
// webpageMeta 是从 HTML head 提取的预览元数据(已按 og>twitter>title/meta 优先级归并)。
type webpageMeta struct {
title string
description string
siteName string
image string
author string
ogType string
}
func (m webpageMeta) empty() bool {
return m.title == "" && m.description == "" && m.siteName == "" && m.image == "" && m.author == ""
}
func (m webpageMeta) pageType() string {
if m.ogType != "" {
return m.ogType
}
if m.image != "" && m.title == "" && m.description == "" {
return "photo"
}
return "article"
}
// parseWebPageMeta 扫描 HTML head 的 <meta>/<title>,提取 OpenGraph/Twitter-card/标准元数据。
// 遇到 <body> 或 </head> 即停止(元数据都在 head)。og:image 相对 URL 按 baseURL 解析为绝对。
func parseWebPageMeta(htmlBytes []byte, baseURL string) webpageMeta {
var (
m webpageMeta
ogTitle, twTitle, docTitle string
ogDesc, twDesc, metaDesc string
ogImage, twImage string
inTitle bool
)
z := html.NewTokenizer(bytes.NewReader(htmlBytes))
scan:
for {
switch z.Next() {
case html.ErrorToken:
break scan
case html.StartTagToken, html.SelfClosingTagToken:
name, hasAttr := z.TagName()
switch string(name) {
case "meta":
key, content := metaKeyContent(z, hasAttr)
if content == "" {
continue
}
switch key {
case "og:title":
ogTitle = content
case "og:description":
ogDesc = content
case "og:site_name":
m.siteName = content
case "og:type":
m.ogType = content
case "og:image", "og:image:url", "og:image:secure_url":
if ogImage == "" {
ogImage = content
}
case "twitter:title":
twTitle = content
case "twitter:description":
twDesc = content
case "twitter:image", "twitter:image:src":
if twImage == "" {
twImage = content
}
case "description":
metaDesc = content
case "author", "article:author":
if m.author == "" {
m.author = content
}
}
case "title":
inTitle = true
case "body":
break scan
}
case html.TextToken:
if inTitle && docTitle == "" {
docTitle = strings.TrimSpace(string(z.Text()))
}
case html.EndTagToken:
name, _ := z.TagName()
switch string(name) {
case "title":
inTitle = false
case "head":
break scan
}
}
}
m.title = firstNonEmpty(ogTitle, twTitle, docTitle)
m.description = firstNonEmpty(ogDesc, twDesc, metaDesc)
if img := firstNonEmpty(ogImage, twImage); img != "" {
if abs, ok := resolveAbsoluteURL(baseURL, img); ok {
m.image = abs
}
}
return m
}
// metaKeyContent 从一个 <meta> 标签收集 (property|name) 与 content。
func metaKeyContent(z *html.Tokenizer, hasAttr bool) (string, string) {
var key, content string
for hasAttr {
var k, v []byte
k, v, hasAttr = z.TagAttr()
switch strings.ToLower(string(k)) {
case "property", "name":
if key == "" {
key = strings.ToLower(strings.TrimSpace(string(v)))
}
case "content":
content = strings.TrimSpace(string(v))
}
}
return key, content
}
func resolveAbsoluteURL(base, ref string) (string, bool) {
b, err := url.Parse(base)
if err != nil {
return "", false
}
r, err := url.Parse(strings.TrimSpace(ref))
if err != nil {
return "", false
}
abs := b.ResolveReference(r)
if abs.Scheme != "http" && abs.Scheme != "https" {
return "", false
}
return abs.String(), true
}
func webpageDisplayURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
return strings.TrimPrefix(u.Host, "www.")
}
// webpageContentHash 对卡片内容算稳定 31-bit 哈希(webPage.hash 是 TL int,用于 getWebPage
// NotModified 短路)。仅覆盖文本字段,预览图变化不计入(同 URL 预览图按内容寻址已去重)。
func webpageContentHash(p domain.MessageWebPage) int {
h := fnv.New32a()
for _, s := range []string{p.URL, p.Title, p.Description, p.SiteName, p.Author, p.Type} {
_, _ = h.Write([]byte(s))
_, _ = h.Write([]byte{0})
}
return int(h.Sum32() & 0x7fffffff)
}
func isHTMLContentType(ct string) bool {
return ct == "" || ct == "text/html" || ct == "application/xhtml+xml"
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

View file

@ -0,0 +1,228 @@
package files
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
)
// newWebpageTestService 构造一个带 loopback-allowed 抓取器的 Service(生产恒禁 loopback)。
func newWebpageTestService(t *testing.T, allowPrivate bool) *Service {
t.Helper()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
svc.webpage = newWebpageFetcher(DefaultWebPagePreviewMaxBytes, 600, allowPrivate)
return svc
}
func TestResolveWebPageDoneCardWithImage(t *testing.T) {
imgBytes := testJPEG(t, 8, 6)
var pageHits, imgHits int32
mux := http.NewServeMux()
mux.HandleFunc("/img.jpg", func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&imgHits, 1)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(imgBytes)
})
mux.HandleFunc("/article", func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&pageHits, 1)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<html><head>
<title>Fallback Title</title>
<meta property="og:title" content="OG Title">
<meta property="og:description" content="OG Description">
<meta property="og:site_name" content="Example Site">
<meta property="og:type" content="article">
<meta property="og:image" content="/img.jpg">
</head><body>ignored body</body></html>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newWebpageTestService(t, true)
ctx := context.Background()
pageURL := srv.URL + "/article"
page, err := svc.ResolveWebPage(ctx, pageURL)
if err != nil {
t.Fatalf("ResolveWebPage: %v", err)
}
if page.State != domain.MessageWebPageStateDone {
t.Fatalf("state = %q, want done", page.State)
}
if page.Title != "OG Title" || page.Description != "OG Description" || page.SiteName != "Example Site" || page.Type != "article" {
t.Fatalf("card fields = %+v", page)
}
if page.Photo == nil || page.Photo.ID == 0 || len(page.Photo.Sizes) == 0 {
t.Fatalf("expected minted preview photo, got %+v", page.Photo)
}
// id == url_hash(保证 pending↔done 关联)。
normalized, _ := domain.NormalizeWebPageURL(pageURL)
if page.ID != domain.WebPageURLHash(normalized) {
t.Fatalf("webPage id %d != url_hash %d", page.ID, domain.WebPageURLHash(normalized))
}
// 第二次解析命中 L1 缓存(singleflight/LRU),不再打上游。
if _, err := svc.ResolveWebPage(ctx, pageURL); err != nil {
t.Fatalf("second ResolveWebPage: %v", err)
}
if h := atomic.LoadInt32(&pageHits); h != 1 {
t.Fatalf("page fetched %d times, want 1 (cache dedup)", h)
}
if h := atomic.LoadInt32(&imgHits); h != 1 {
t.Fatalf("image fetched %d times, want 1", h)
}
}
// TestWebPageMaybeRefreshStale 验证超龄卡片触发后台 stale-while-revalidate 刷新(写回 done)。
func TestWebPageMaybeRefreshStale(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/article", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<html><head><meta property="og:title" content="Fresh"></head></html>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
ctx := context.Background()
svc := newWebpageTestService(t, true)
normalized, _ := domain.NormalizeWebPageURL(srv.URL + "/article")
urlHash := domain.WebPageURLHash(normalized)
// 触发刷新(refreshedAt=1 → 远超 TTL)。
svc.webpage.maybeRefresh(svc, normalized, urlHash, 1)
// 轮询直到刷新写回 done 卡片。
var ok bool
for i := 0; i < 100; i++ {
if page, _, found, err := svc.media.GetWebPageByURLHash(ctx, urlHash); err == nil && found && page.State == domain.MessageWebPageStateDone && page.Title == "Fresh" {
ok = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !ok {
t.Fatalf("stale refresh did not write fresh done card")
}
// refreshedAt=0 或新鲜 → 不刷新(不 panic、立即返回)。
svc.webpage.maybeRefresh(svc, normalized, urlHash, 0)
svc.webpage.maybeRefresh(svc, normalized, urlHash, int(time.Now().Unix()))
}
// TestResolveWebPageTerminalFailureNegativeCached 验证 4xx(终态)解析为空预览并负缓存——
// 第二次不再打上游(否则每次按键/发送重复抓取坏 URL)。
func TestResolveWebPageTerminalFailureNegativeCached(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
svc := newWebpageTestService(t, true)
ctx := context.Background()
page, err := svc.ResolveWebPage(ctx, srv.URL+"/x")
if err != nil {
t.Fatalf("404 should be terminal-empty (not error): %v", err)
}
if page.State != domain.MessageWebPageStateEmpty {
t.Fatalf("state = %q, want empty", page.State)
}
if _, err := svc.ResolveWebPage(ctx, srv.URL+"/x"); err != nil {
t.Fatalf("second resolve: %v", err)
}
if h := atomic.LoadInt32(&hits); h != 1 {
t.Fatalf("terminal URL fetched %d times, want 1 (negative cached)", h)
}
}
// TestResolveWebPageTransientFailureNotCached 验证 5xx(瞬时)返回 error 且不缓存——可重试。
func TestResolveWebPageTransientFailureNotCached(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
svc := newWebpageTestService(t, true)
ctx := context.Background()
if _, err := svc.ResolveWebPage(ctx, srv.URL+"/x"); err == nil {
t.Fatalf("500 should return transient error")
}
_, _ = svc.ResolveWebPage(ctx, srv.URL+"/x")
if h := atomic.LoadInt32(&hits); h != 2 {
t.Fatalf("transient URL fetched %d times, want 2 (not cached, retryable)", h)
}
}
func TestResolveWebPageNoMetadataIsEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<html><head></head><body>no meta here</body></html>`)
}))
defer srv.Close()
svc := newWebpageTestService(t, true)
page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x")
if err != nil {
t.Fatalf("ResolveWebPage: %v", err)
}
if page.State != domain.MessageWebPageStateEmpty {
t.Fatalf("state = %q, want empty", page.State)
}
}
func TestResolveWebPageNonHTMLIsEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-1.4 binary"))
}))
defer srv.Close()
svc := newWebpageTestService(t, true)
page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/doc")
if err != nil {
t.Fatalf("ResolveWebPage: %v", err)
}
if page.State != domain.MessageWebPageStateEmpty {
t.Fatalf("state = %q, want empty for non-HTML", page.State)
}
}
// TestResolveWebPageSSRFBlocksLoopback 验证生产配置(allowPrivate=false)拦截指向 loopback 的 URL。
func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<html><head><meta property="og:title" content="secret"></head></html>`)
}))
defer srv.Close()
svc := newWebpageTestService(t, false) // 生产口径:禁 loopback
if _, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x"); err == nil {
t.Fatalf("expected SSRF guard to block loopback fetch")
}
}
func TestResolveWebPageDisabled(t *testing.T) {
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2) // 未启用 webpage 抓取
if _, err := svc.ResolveWebPage(context.Background(), "https://example.com"); err == nil {
t.Fatalf("expected ErrWebPagePreviewDisabled")
}
}

View file

@ -0,0 +1,121 @@
// Package groupcalls 实现超级群语音聊天(group call)的信令业务层:
// ID/access_hash 分配与 store 编排。权限(admin/成员资格)由 rpc 层校验,
// version 单调性与并发串行化由 store 层事务保证。
package groupcalls
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 是群通话业务服务。
type Service struct {
store store.GroupCallStore
}
// NewService 创建群通话服务。
func NewService(st store.GroupCallStore) *Service {
return &Service{store: st}
}
// Create 分配 id/access_hash 并建会。
func (s *Service) Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error) {
id, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
return s.store.CreateGroupCall(ctx, domain.GroupCall{
ID: id,
AccessHash: accessHash,
ChannelID: channelID,
CreatorUserID: creatorUserID,
Title: title,
Version: 1,
CreatedAt: now,
})
}
func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
return s.store.GetGroupCall(ctx, callID)
}
func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
return s.store.JoinGroupCall(ctx, req)
}
func (s *Service) Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) {
return s.store.LeaveGroupCall(ctx, callID, userID, now)
}
func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
return s.store.DiscardGroupCall(ctx, callID, now)
}
func (s *Service) Touch(ctx context.Context, callID, userID int64, now int) ([]int64, bool, error) {
return s.store.TouchParticipant(ctx, callID, userID, now)
}
func (s *Service) Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) {
return s.store.GetParticipant(ctx, callID, userID)
}
func (s *Service) Participants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) {
return s.store.ListParticipants(ctx, callID, offset, limit)
}
func (s *Service) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) {
return s.store.UpdateParticipant(ctx, callID, userID, update)
}
func (s *Service) SetTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) {
return s.store.SetGroupCallTitle(ctx, callID, title)
}
func (s *Service) SetJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) {
return s.store.SetGroupCallJoinMuted(ctx, callID, joinMuted)
}
func (s *Service) SetStartedMessageID(ctx context.Context, callID int64, msgID int) error {
return s.store.SetStartedMessageID(ctx, callID, msgID)
}
func (s *Service) SweepStale(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) {
return s.store.SweepStaleParticipants(ctx, checkOlderThan, now, limit)
}
func (s *Service) ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) {
return s.store.ResetAllParticipants(ctx, now)
}
func (s *Service) NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) {
return s.store.NextRaiseHandRating(ctx, callID)
}
func (s *Service) SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error {
return s.store.SetParticipantOverride(ctx, callID, setterUserID, targetUserID, override, clear)
}
func (s *Service) ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) {
return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID)
}
func randomPositiveInt64() (int64, error) {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, fmt.Errorf("groupcalls: random id: %w", err)
}
v := int64(binary.BigEndian.Uint64(buf[:]) >> 1)
if v == 0 {
v = 1
}
return v, nil
}

View file

@ -2,58 +2,165 @@ package help
import (
"context"
"encoding/json"
"hash/crc32"
"sync"
"telesrv/internal/domain"
"telesrv/internal/seed/catalog"
"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}`
// upload_markup_video=true 即官方默认(emoji/sticker 头像由客户端本地渲染 mp4 后随
// markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认——
// 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。
// emojies_send_dice 是客户端把「单 emoji 消息」转成 InputMediaDice 的白名单;
// 必须与 rpc 层 diceValueSides 的取值表保持同步(⚽ 用裸码点,客户端会自行匹配变体形态)。
// tdesktop_config_map 是 TDesktop 位置选点器(WebView+Mapbox GL)与 business 位置设置的
// Mapbox access token:maps/geo=聊天附件选点(地图瓦片/地理编码),bmaps/bgeo=business 位置。
// 该 token 必须来自运行期配置;未配置时不下发 tdesktop_config_map,避免公共源码携带第三方 token。
// premium 相关 key(docs/premium-module.md):
// - premium_purchase_blocked=false 必须显式下发:DrKLO(ProfileActivity:12059)与 TDesktop
// (window_peer_menu.cpp:1549)把「Send a Gift」(star gift)入口与 premiumCanBuy()=
// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift
// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium
// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。
// - stargifts_blocked=false 必须显式下发:DrKLO MessagesController:1752 缺省 stargiftsBlocked=
// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
// 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。
// - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号
// 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法
// 建文件夹/采纳 getSuggestedDialogFilters 模板。
// - *_limit_default/_premium 双档限额对齐官方值;其中 about/dialogs_pinned/
// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/
// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce,
// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20,
// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发
// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 17 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//
// app config 与国家区号属「启动后基本不变」的参考目录:运行期无写入(UpsertAppConfig/
// UpsertCountries 仅 seed/迁移用),故各加载一次缓存进内存,之后所有 RPC 走内存、不再查库
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
// internal/seed/catalog(go:embed 一次解析),本就在内存。
type Service struct {
appConfigs store.AppConfigStore
countries store.CountryStore
appConfigs store.AppConfigStore
countries store.CountryStore
mapboxToken string
appConfigOnce sync.Once
appConfigCache domain.AppConfig
countriesOnce sync.Once
countriesCache domain.CountriesList
}
// Option 配置 help 服务运行期默认目录。
type Option func(*Service)
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
func WithMapboxToken(token string) Option {
return func(s *Service) {
s.mapboxToken = token
}
}
// NewService 创建 help 服务。
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service {
return &Service{appConfigs: appConfigs, countries: countries}
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
s := &Service{appConfigs: appConfigs, countries: countries}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
// 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
func defaultAppConfig(mapboxToken string) domain.AppConfig {
jsonBytes := defaultAppConfigJSON(mapboxToken)
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken), JSON: jsonBytes}
}
func defaultAppConfigJSON(mapboxToken string) []byte {
if mapboxToken == "" {
return []byte(tdesktopDefaultAppConfigBase + `}`)
}
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
token, err := json.Marshal(mapboxToken)
if err != nil {
return domain.AppConfig{}, false, err
return []byte(tdesktopDefaultAppConfigBase + `}`)
}
if !found {
cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
tokenJSON := string(token)
return []byte(tdesktopDefaultAppConfigBase + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
}
func defaultAppConfigHashFor(mapboxToken string) int {
if mapboxToken == "" {
return defaultAppConfigHash
}
return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
}
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
cfg := s.loadAppConfig(ctx)
return cfg, hash != 0 && hash == cfg.Hash, nil
}
// GetCountries 返回国家区号目录,hash 命中时返回 notModified。
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
if s == nil {
return defaultAppConfig("")
}
defaultCfg := defaultAppConfig(s.mapboxToken)
s.appConfigOnce.Do(func() {
if s.appConfigs == nil {
s.appConfigCache = defaultCfg
return
}
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
// DB 行允许运维覆盖,但 hash 落后于代码默认值时视为陈旧(历史 seed 残留),
// 以默认值为准——否则新增配置 key 永远被旧行遮蔽(曾导致 emojies_send_dice 未下发)。
// 读失败也回退默认值(默认值恒有效),不让一次瞬时 DB 抖动污染整个进程生命周期的缓存。
if err != nil || !found || cfg.Hash < defaultAppConfigHash {
cfg = defaultCfg
}
s.appConfigCache = cfg
})
return s.appConfigCache
}
// 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()
}
list := s.loadCountries(ctx)
return list, hash != 0 && hash == list.Hash, nil
}
func (s *Service) loadCountries(ctx context.Context) domain.CountriesList {
if s == nil || s.countries == nil {
return defaultCountries()
}
s.countriesOnce.Do(func() {
list, err := s.countries.ListCountries(ctx, "")
if err != nil || len(list.Countries) == 0 {
list = defaultCountries()
}
s.countriesCache = list
})
return s.countriesCache
}
// defaultCountries 返回内置国家区号目录:优先用 catalog 固化的官方全量(~235 国),
// 未 seed 时回退最小集(US/CN)。countries 表通常为空,故这就是生产实际下发的目录。
func defaultCountries() domain.CountriesList {
if c := catalog.Countries(); len(c.Countries) > 0 {
return c
}
return domain.CountriesList{
Hash: 1,
Countries: []domain.Country{
@ -61,14 +168,14 @@ func defaultCountries() domain.CountriesList {
ISO2: "US",
DefaultName: "United States",
CountryCodes: []domain.CountryCode{
{CountryCode: "1", Prefixes: []string{"1"}},
{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}},
},
},
{
ISO2: "CN",
DefaultName: "China",
CountryCodes: []domain.CountryCode{
{CountryCode: "86", Prefixes: []string{"86"}},
{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}},
},
},
},

View file

@ -0,0 +1,108 @@
package help
import (
"context"
"encoding/json"
"testing"
)
// TestAppConfigPremiumKeys 断言 premium 相关 key 完整下发且 hash 已递增:
// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
func TestAppConfigPremiumKeys(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
}
var decoded map[string]any
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
}
// DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
}
wantNumbers := map[string]float64{
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5,
"dialogs_pinned_limit_premium": 10,
"dialogs_folder_pinned_limit_default": 100,
"dialogs_folder_pinned_limit_premium": 200,
"saved_dialogs_pinned_limit_default": 5,
"saved_dialogs_pinned_limit_premium": 100,
"caption_length_limit_default": 1024,
"caption_length_limit_premium": 4096,
"channels_limit_default": 500,
"channels_limit_premium": 1000,
"dialog_filters_limit_default": 10,
"dialog_filters_limit_premium": 20,
"upload_max_fileparts_default": 4000,
"upload_max_fileparts_premium": 8000,
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
}
for key, want := range wantNumbers {
got, ok := decoded[key].(float64)
if !ok || got != want {
t.Errorf("appConfig[%q] = %v, want %v", key, decoded[key], want)
}
}
// 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。
for _, forbidden := range []string{"chatlists_joined_limit_default", "stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
if _, ok := decoded[forbidden]; ok {
t.Errorf("appConfig 不应包含 %q", forbidden)
}
}
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
var decoded map[string]any
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
if _, ok := decoded["tdesktop_config_map"]; ok {
t.Fatal("tdesktop_config_map present without configured Mapbox token")
}
}
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash == defaultAppConfigHash {
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
}
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
}
var decoded map[string]any
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
configMap, ok := decoded["tdesktop_config_map"].(map[string]any)
if !ok {
t.Fatalf("tdesktop_config_map = %T, want object", decoded["tdesktop_config_map"])
}
for _, key := range []string{"maps", "geo", "bmaps", "bgeo"} {
if got := configMap[key]; got != "pk.test-token" {
t.Fatalf("tdesktop_config_map[%q] = %v, want token", key, got)
}
}
}

View file

@ -13,7 +13,7 @@ import (
var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`)
// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。
// ParseTDesktopFile 解析客户端 .strings 文件为 domain 语言包。
func ParseTDesktopFile(path string) (domain.LangPack, error) {
pack, err := packFromFilename(path)
if err != nil {
@ -21,7 +21,7 @@ func ParseTDesktopFile(path string) (domain.LangPack, error) {
}
data, err := os.ReadFile(path)
if err != nil {
return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err)
return domain.LangPack{}, fmt.Errorf("read langpack %q: %w", path, err)
}
plain := make([]domain.LangPackString, 0)
@ -54,22 +54,37 @@ func ParseTDesktopFile(path string) (domain.LangPack, error) {
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))
idx := strings.LastIndex(name, "_v")
if idx <= 0 || idx+2 >= len(name) {
return domain.LangPack{}, fmt.Errorf("invalid 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:])
version, err := strconv.Atoi(name[idx+2:])
if err != nil {
return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err)
return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", name[idx+2:], err)
}
head := name[:idx]
dirPack := filepath.Base(filepath.Dir(path))
prefix := dirPack + "_"
langPack := ""
langCode := ""
if dirPack != "." && dirPack != "" && strings.HasPrefix(head, prefix) {
langPack = dirPack
langCode = strings.TrimPrefix(head, prefix)
} else {
sep := strings.Index(head, "_")
if sep <= 0 || sep+1 >= len(head) {
return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path))
}
langPack = head[:sep]
langCode = head[sep+1:]
}
if langPack == "" || langCode == "" {
return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path))
}
return domain.LangPack{
LangPack: "tdesktop",
LangCode: rest[:idx],
LangPack: langPack,
LangCode: langCode,
Version: version,
}, nil
}

View file

@ -1,9 +1,12 @@
package langpack
import (
"context"
"os"
"path/filepath"
"testing"
"telesrv/internal/store/memory"
)
func TestParseTDesktopFile(t *testing.T) {
@ -35,3 +38,61 @@ func TestParseTDesktopFile(t *testing.T) {
t.Fatalf("plural string = %+v", plural)
}
}
func TestParseClientLangPackFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "weba_en_v12000000.strings")
if err := os.WriteFile(path, []byte(`
"NewMessageTitle" = "New Message";
`), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
pack, err := ParseTDesktopFile(path)
if err != nil {
t.Fatalf("parse: %v", err)
}
if pack.LangPack != "weba" || pack.LangCode != "en" || pack.Version != 12000000 {
t.Fatalf("pack meta = %+v", pack)
}
if len(pack.Strings) != 1 || pack.Strings[0].Key != "NewMessageTitle" {
t.Fatalf("strings = %+v", pack.Strings)
}
}
func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
root := t.TempDir()
for _, item := range []struct {
dir string
file string
key string
}{
{dir: "tdesktop", file: "tdesktop_en_v1.strings", key: "lng_language_name"},
{dir: "weba", file: "weba_en_v2.strings", key: "NewMessageTitle"},
} {
dir := filepath.Join(root, item.dir)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
content := []byte(`"` + item.key + `" = "value";`)
if err := os.WriteFile(filepath.Join(dir, item.file), content, 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
store := memory.NewLangPackStore()
service := NewService(store)
seeded, err := service.SeedDirectory(context.Background(), root)
if err != nil {
t.Fatalf("seed: %v", err)
}
if seeded != 2 {
t.Fatalf("seeded = %d, want 2", seeded)
}
pack, err := service.GetLangPack(context.Background(), "weba", "en")
if err != nil {
t.Fatalf("get weba pack: %v", err)
}
if pack.Version != 2 || len(pack.Strings) != 1 || pack.Strings[0].Key != "NewMessageTitle" {
t.Fatalf("weba pack = %+v", pack)
}
}

View file

@ -22,35 +22,34 @@ func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
}
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()))
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return seeded, err
return err
}
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
return nil
}
pack, err := ParseTDesktopFile(path)
if err != nil {
return err
}
existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version)
if err != nil {
return seeded, err
return err
}
if existing.Version >= pack.Version {
continue
return nil
}
if err := s.packs.UpsertPack(ctx, pack); err != nil {
return seeded, err
return err
}
seeded += len(pack.Strings)
return nil
})
if err != nil {
return seeded, fmt.Errorf("walk langpack seed dir: %w", err)
}
return seeded, nil
}

View file

@ -12,6 +12,16 @@ type DispatchOutboxRetentionStore interface {
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
}
// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key 绑定。
type TempAuthKeyRetentionStore interface {
DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error)
}
// tempAuthKeyExpiryGrace 是 temp key 过期后的回收宽限:ResolveAuthKey 对
// 「已过期但 perm 已授权」的绑定是容忍的,立即删除会突然断掉这批宽限中的
// 连接;回收目标是清堆积,晚一天无妨。
const tempAuthKeyExpiryGrace = 24 * time.Hour
// RetentionWorker 周期性回收存储中的死数据。
//
// 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持
@ -22,13 +32,14 @@ type DispatchOutboxRetentionStore interface {
// 长期膨胀作为已知 todo。
type RetentionWorker struct {
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
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 {
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
if logger == nil {
logger = zap.NewNop()
}
@ -43,6 +54,7 @@ func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger,
}
return &RetentionWorker{
outbox: outbox,
tempKeys: tempKeys,
logger: logger,
retention: retention,
interval: interval,
@ -71,4 +83,13 @@ func (w *RetentionWorker) runOnce(ctx context.Context) {
} else if outboxDeleted > 0 {
w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted))
}
if w.tempKeys != nil {
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)
if err != nil {
w.logger.Warn("回收过期 temp auth key 绑定失败", zap.Error(err))
} else if tempDeleted > 0 {
w.logger.Info("回收过期 temp auth key 绑定完成", zap.Int("deleted", tempDeleted))
}
}
}

View file

@ -0,0 +1,59 @@
package maintenance
import (
"context"
"testing"
"time"
"go.uber.org/zap"
)
type fakeOutboxRetention struct {
calls int
}
func (f *fakeOutboxRetention) DeleteFailed(context.Context, time.Duration, int) (int, error) {
f.calls++
return 0, nil
}
type fakeTempKeyRetention struct {
calls int
expiredBefore int64
limit int
}
func (f *fakeTempKeyRetention) DeleteExpired(_ context.Context, expiredBefore int64, limit int) (int, error) {
f.calls++
f.expiredBefore = expiredBefore
f.limit = limit
return 3, nil
}
func TestRetentionWorkerReclaimsExpiredTempKeys(t *testing.T) {
outbox := &fakeOutboxRetention{}
temp := &fakeTempKeyRetention{}
w := NewRetentionWorker(outbox, temp, zap.NewNop(), time.Hour, time.Hour, 100)
w.runOnce(context.Background())
if outbox.calls != 1 || temp.calls != 1 {
t.Fatalf("calls outbox=%d temp=%d, want 1/1", outbox.calls, temp.calls)
}
if temp.limit != 100 {
t.Fatalf("limit = %d, want batch 100", temp.limit)
}
wantBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
if diff := temp.expiredBefore - wantBefore; diff < -5 || diff > 5 {
t.Fatalf("expiredBefore = %d, want ≈ now-grace (%d)", temp.expiredBefore, wantBefore)
}
}
func TestRetentionWorkerSkipsNilTempKeyStore(t *testing.T) {
outbox := &fakeOutboxRetention{}
w := NewRetentionWorker(outbox, nil, zap.NewNop(), time.Hour, time.Hour, 100)
w.runOnce(context.Background()) // 不应 panic
if outbox.calls != 1 {
t.Fatalf("outbox calls = %d, want 1", outbox.calls)
}
}

View file

@ -0,0 +1,27 @@
package messages
import (
"context"
"strings"
"telesrv/internal/domain"
)
type EchoBusinessAutomationProvider struct{}
func NewEchoBusinessAutomationProvider() EchoBusinessAutomationProvider {
return EchoBusinessAutomationProvider{}
}
func (EchoBusinessAutomationProvider) BusinessAutomationReplies(_ context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
body := input.TriggerMessage.Body
if strings.TrimSpace(body) == "" {
return nil, nil
}
return []domain.QuickReplyMessage{{
ID: 1,
Date: input.Now,
Message: body,
Entities: append([]domain.MessageEntity(nil), input.TriggerMessage.Entities...),
}}, nil
}

View file

@ -0,0 +1,418 @@
package messages
import (
"context"
"encoding/binary"
"errors"
"hash/fnv"
"time"
"unicode/utf8"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type BusinessAutomationOnlineChecker interface {
IsUserOnline(userID int64) bool
}
type BusinessAutomationReplyProvider interface {
BusinessAutomationReplies(ctx context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error)
}
type BusinessAutomationReplyInput struct {
Kind domain.BusinessAutomationKind
OwnerUserID int64
CustomerUserID int64
Profile domain.BusinessProfile
TriggerMessage domain.Message
Templates []domain.QuickReplyMessage
Now int
}
type businessAutomationConfig struct {
store store.BusinessAutomationStore
online BusinessAutomationOnlineChecker
replyProvider BusinessAutomationReplyProvider
}
type BusinessAutomationOption func(*businessAutomationConfig)
func WithBusinessAutomation(business store.BusinessAutomationStore, opts ...BusinessAutomationOption) Option {
return func(s *Service) {
cfg := &businessAutomationConfig{store: business}
for _, opt := range opts {
opt(cfg)
}
s.business = cfg
}
}
func WithBusinessAutomationOnlineChecker(online BusinessAutomationOnlineChecker) BusinessAutomationOption {
return func(cfg *businessAutomationConfig) {
cfg.online = online
}
}
func WithBusinessAutomationReplyProvider(provider BusinessAutomationReplyProvider) BusinessAutomationOption {
return func(cfg *businessAutomationConfig) {
cfg.replyProvider = provider
}
}
type businessAutomationContext struct {
ownerUserID int64
customerUserID int64
existingChat bool
lastActivityDate int
isContact bool
}
func (s *Service) prepareBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest) (businessAutomationContext, bool) {
if !s.shouldConsiderBusinessAutomation(req) {
return businessAutomationContext{}, false
}
out := businessAutomationContext{
ownerUserID: req.RecipientUserID,
customerUserID: req.SenderUserID,
}
if s.dialogs != nil {
list, err := s.dialogs.ListByPeers(ctx, req.RecipientUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: req.SenderUserID}})
if err != nil {
return businessAutomationContext{}, false
}
if len(list.Dialogs) > 0 && list.Dialogs[0].TopMessage > 0 {
out.existingChat = true
out.lastActivityDate = list.Dialogs[0].TopMessageDate
}
}
if s.contacts != nil {
_, ok, err := s.contacts.Get(ctx, req.RecipientUserID, req.SenderUserID)
if err != nil {
return businessAutomationContext{}, false
}
out.isContact = ok
}
return out, true
}
func (s *Service) shouldConsiderBusinessAutomation(req domain.SendPrivateTextRequest) bool {
if s == nil || s.business == nil || s.business.store == nil {
return false
}
if req.BusinessAutomationKind != "" || req.RecipientBlocked {
return false
}
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.SenderUserID == req.RecipientUserID {
return false
}
if s.botResponder != nil && (s.botResponder.HandlesBot(req.SenderUserID) || s.botResponder.HandlesBot(req.RecipientUserID)) {
return false
}
return true
}
func (s *Service) runBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest, res domain.SendPrivateTextResult, automation businessAutomationContext) {
now := req.Date
if now == 0 {
now = res.RecipientMessage.Date
}
if now == 0 {
now = int(time.Now().Unix())
}
trigger := res.RecipientMessage
if trigger.ID == 0 {
return
}
delivered, err := s.deliverConnectedBusinessBotAutomation(ctx, trigger, automation, now)
if err != nil || delivered {
return
}
profile, ok, err := s.business.store.GetBusinessProfile(ctx, automation.ownerUserID)
if err != nil || !ok {
return
}
profile.UserID = automation.ownerUserID
if profile.Greeting != nil && s.businessGreetingEligible(*profile.Greeting, automation, now) {
_ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationGreeting, profile.Greeting.ShortcutID, now)
return
}
if profile.Away != nil && s.businessAwayEligible(ctx, profile, *profile.Away, automation, now) {
_ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAway, profile.Away.ShortcutID, now)
}
}
func (s *Service) businessGreetingEligible(greeting domain.BusinessGreetingMessage, automation businessAutomationContext, now int) bool {
if !businessRecipientsMatch(greeting.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
return false
}
if !automation.existingChat {
return true
}
if automation.lastActivityDate <= 0 {
return false
}
return now-automation.lastActivityDate >= greeting.NoActivityDays*24*60*60
}
func (s *Service) businessAwayEligible(ctx context.Context, profile domain.BusinessProfile, away domain.BusinessAwayMessage, automation businessAutomationContext, now int) bool {
if !businessRecipientsMatch(away.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
return false
}
if away.OfflineOnly && s.business.online != nil && s.business.online.IsUserOnline(automation.ownerUserID) {
return false
}
if !businessAwayScheduleActive(profile.WorkHours, away.Schedule, now) {
return false
}
last, ok, err := s.business.store.LastBusinessAutomationDelivery(ctx, automation.ownerUserID, automation.customerUserID, domain.BusinessAutomationAway)
if err != nil {
return false
}
if !ok {
return true
}
if away.Schedule.Kind == domain.BusinessAwayScheduleCustom && last.SentAt < away.Schedule.StartDate {
return true
}
return now-last.SentAt >= domain.BusinessAwayCooldownSeconds
}
func (s *Service) deliverConnectedBusinessBotAutomation(ctx context.Context, trigger domain.Message, automation businessAutomationContext, now int) (bool, error) {
if s.business.replyProvider == nil {
return false, nil
}
connected, ok, err := s.business.store.GetConnectedBusinessBot(ctx, automation.ownerUserID)
if err != nil || !ok || connected.BotUserID == 0 || !connected.Rights.Reply {
return false, err
}
state, stateFound, err := s.business.store.GetConnectedBusinessBotPeerState(ctx, automation.ownerUserID, automation.customerUserID)
if err != nil {
return false, err
}
if stateFound && (state.Paused || state.Disabled) {
return false, nil
}
if !domain.BusinessBotRecipientsMatch(connected.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
return false, nil
}
profile := domain.BusinessProfile{UserID: automation.ownerUserID}
msgs, err := s.businessAutomationMessages(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAI, 0, now)
if err != nil || len(msgs) == 0 {
return false, err
}
if s.contacts != nil {
blocked, err := s.contacts.IsBlocked(ctx, automation.customerUserID, automation.ownerUserID)
if err != nil || blocked {
return false, err
}
}
reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{
OwnerUserID: automation.ownerUserID,
PeerUserID: automation.customerUserID,
Kind: domain.BusinessAutomationAI,
TriggerMessageID: trigger.ID,
ShortcutID: 0,
SentAt: now,
})
if err != nil || !reserved {
return false, err
}
for i, msg := range msgs {
_, err := s.SendPrivateText(ctx, automation.ownerUserID, domain.SendPrivateTextRequest{
SenderUserID: automation.ownerUserID,
RecipientUserID: automation.customerUserID,
RandomID: businessAutomationRandomID(domain.BusinessAutomationAI, automation.ownerUserID, automation.customerUserID, trigger.ID, msg.ID, i),
Message: msg.Message,
Entities: append([]domain.MessageEntity(nil), msg.Entities...),
Date: now,
ViaBotID: connected.BotUserID,
BusinessAutomationKind: domain.BusinessAutomationAI,
})
if err != nil {
return false, err
}
}
return true, nil
}
func (s *Service) deliverBusinessAutomation(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) error {
if shortcutID <= 0 {
return nil
}
msgs, err := s.businessAutomationMessages(ctx, profile, trigger, customerUserID, kind, shortcutID, now)
if err != nil || len(msgs) == 0 {
return err
}
if s.contacts != nil {
blocked, err := s.contacts.IsBlocked(ctx, customerUserID, profile.UserID)
if err != nil || blocked {
return err
}
}
reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{
OwnerUserID: profile.UserID,
PeerUserID: customerUserID,
Kind: kind,
TriggerMessageID: trigger.ID,
ShortcutID: shortcutID,
SentAt: now,
})
if err != nil || !reserved {
return err
}
for i, msg := range msgs {
_, err := s.SendPrivateText(ctx, profile.UserID, domain.SendPrivateTextRequest{
SenderUserID: profile.UserID,
RecipientUserID: customerUserID,
RandomID: businessAutomationRandomID(kind, profile.UserID, customerUserID, trigger.ID, msg.ID, i),
Message: msg.Message,
Entities: append([]domain.MessageEntity(nil), msg.Entities...),
Date: now,
BusinessAutomationKind: kind,
})
if err != nil {
return err
}
}
return nil
}
func (s *Service) businessAutomationMessages(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) ([]domain.QuickReplyMessage, error) {
var templateMessages []domain.QuickReplyMessage
templates, err := s.business.store.GetQuickReplyMessages(ctx, profile.UserID, shortcutID, nil)
if err != nil {
if s.business.replyProvider == nil || !errors.Is(err, domain.ErrShortcutInvalid) {
return nil, err
}
} else {
templateMessages = templates.Messages
}
msgs := cloneBusinessAutomationMessages(templateMessages)
if s.business.replyProvider != nil {
msgs, err = s.business.replyProvider.BusinessAutomationReplies(ctx, BusinessAutomationReplyInput{
Kind: kind,
OwnerUserID: profile.UserID,
CustomerUserID: customerUserID,
Profile: profile,
TriggerMessage: trigger,
Templates: cloneBusinessAutomationMessages(templateMessages),
Now: now,
})
if err != nil {
return nil, err
}
msgs = cloneBusinessAutomationMessages(msgs)
}
out := make([]domain.QuickReplyMessage, 0, len(msgs))
for _, msg := range msgs {
if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount {
continue
}
out = append(out, msg)
if len(out) >= domain.MaxQuickReplyMessages {
break
}
}
return out, nil
}
func businessRecipientsMatch(recipients domain.BusinessRecipients, existingChat, isContact bool, userID int64) bool {
selected := false
if existingChat && recipients.ExistingChats {
selected = true
}
if !existingChat && recipients.NewChats {
selected = true
}
if isContact && recipients.Contacts {
selected = true
}
if !isContact && recipients.NonContacts {
selected = true
}
for _, id := range recipients.Users {
if id == userID {
selected = true
break
}
}
if recipients.ExcludeSelected {
return !selected
}
return selected
}
func businessAwayScheduleActive(hours *domain.BusinessWorkHours, schedule domain.BusinessAwaySchedule, now int) bool {
switch schedule.Kind {
case domain.BusinessAwayScheduleAlways:
return true
case domain.BusinessAwayScheduleCustom:
return now >= schedule.StartDate && now < schedule.EndDate
case domain.BusinessAwayScheduleOutsideWorkHours:
open, ok := businessWorkHoursOpen(hours, now)
return ok && !open
default:
return false
}
}
func businessWorkHoursOpen(hours *domain.BusinessWorkHours, now int) (bool, bool) {
if hours == nil || hours.TimezoneID == "" || len(hours.WeeklyOpen) == 0 {
return false, false
}
loc, err := time.LoadLocation(hours.TimezoneID)
if err != nil {
return false, false
}
local := time.Unix(int64(now), 0).In(loc)
weekday := (int(local.Weekday()) + 6) % 7
minute := weekday*24*60 + local.Hour()*60 + local.Minute()
const weekMinutes = 7 * 24 * 60
for _, item := range hours.WeeklyOpen {
if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 {
continue
}
if item.EndMinute <= weekMinutes {
if minute >= item.StartMinute && minute < item.EndMinute {
return true, true
}
continue
}
if minute >= item.StartMinute || minute+weekMinutes < item.EndMinute {
return true, true
}
}
return false, true
}
func businessAutomationRandomID(kind domain.BusinessAutomationKind, ownerUserID, customerUserID int64, triggerMessageID, templateMessageID, index int) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(kind))
_, _ = h.Write([]byte{0})
writeBusinessAutomationHashInt64(h, ownerUserID)
writeBusinessAutomationHashInt64(h, customerUserID)
writeBusinessAutomationHashInt64(h, int64(triggerMessageID))
writeBusinessAutomationHashInt64(h, int64(templateMessageID))
writeBusinessAutomationHashInt64(h, int64(index))
id := int64(h.Sum64() & 0x7fffffffffffffff)
if id == 0 {
return 1
}
return id
}
func writeBusinessAutomationHashInt64(h interface{ Write([]byte) (int, error) }, v int64) {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], uint64(v))
_, _ = h.Write(buf[:])
}
func cloneBusinessAutomationMessages(in []domain.QuickReplyMessage) []domain.QuickReplyMessage {
out := make([]domain.QuickReplyMessage, 0, len(in))
for _, msg := range in {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
out = append(out, msg)
}
return out
}

View file

@ -0,0 +1,163 @@
package messages
import (
"context"
"sync"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
)
const (
defaultPrivateMediaCountReadModelTTL = 24 * time.Hour
privateMediaCountReadModelMaxEntries = 8192
)
type privateMediaCountCacheKey struct {
userID int64
peerID int64
}
type privateMediaCountSnapshot struct {
counts domain.MediaCategoryCounts
hash int64
expireAt time.Time
}
type privateMediaCountReadModelCache struct {
ttl time.Duration
now func() time.Time
mu sync.RWMutex
snapshots map[privateMediaCountCacheKey]privateMediaCountSnapshot
epoch uint64
}
func newPrivateMediaCountReadModelCache(ttl time.Duration) *privateMediaCountReadModelCache {
if ttl <= 0 {
ttl = defaultPrivateMediaCountReadModelTTL
}
return &privateMediaCountReadModelCache{
ttl: ttl,
now: time.Now,
snapshots: make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024),
}
}
func (s *Service) cachedPrivateMediaCounts(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) {
if s.privateMediaCountCache == nil || s.versions == nil {
return s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
}
hash, found, err := s.versions.ReadModelHash(ctx, readmodel.ModelPrivateMediaCounts, userID, domain.PeerTypeUser, peerID)
if err != nil {
return nil, err
}
if !found || hash == 0 {
return s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
}
key := privateMediaCountCacheKey{userID: userID, peerID: peerID}
loadEpoch := s.privateMediaCountCache.cacheEpoch()
if snap, ok := s.privateMediaCountCache.lookup(key, s.privateMediaCountCache.now(), hash); ok {
return clonePrivateMediaCategoryCounts(snap.counts), nil
}
counts, err := s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
if err != nil {
return nil, err
}
s.privateMediaCountCache.putIfEpoch(key, counts, hash, loadEpoch)
return counts, nil
}
func (c *privateMediaCountReadModelCache) lookup(key privateMediaCountCacheKey, now time.Time, currentHash int64) (privateMediaCountSnapshot, bool) {
c.mu.RLock()
snap, ok := c.snapshots[key]
c.mu.RUnlock()
if !ok || !snap.expireAt.After(now) {
if ok {
c.invalidate(key)
}
return privateMediaCountSnapshot{}, false
}
if currentHash != 0 && snap.hash != currentHash {
return privateMediaCountSnapshot{}, false
}
return snap, true
}
func (c *privateMediaCountReadModelCache) putIfEpoch(key privateMediaCountCacheKey, counts domain.MediaCategoryCounts, hash int64, expectedEpoch uint64) {
if c == nil || key.userID == 0 || key.peerID == 0 || hash == 0 {
return
}
if c.cacheEpoch() != expectedEpoch {
return
}
snap := privateMediaCountSnapshot{
counts: clonePrivateMediaCategoryCounts(counts),
hash: hash,
expireAt: c.now().Add(c.ttl),
}
c.mu.Lock()
defer c.mu.Unlock()
if c.epoch != expectedEpoch {
return
}
if len(c.snapshots) >= privateMediaCountReadModelMaxEntries {
c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024)
}
c.snapshots[key] = snap
}
func (c *privateMediaCountReadModelCache) invalidate(keys ...privateMediaCountCacheKey) {
if c == nil || len(keys) == 0 {
return
}
c.mu.Lock()
c.epoch++
for _, key := range keys {
delete(c.snapshots, key)
}
c.mu.Unlock()
}
func (c *privateMediaCountReadModelCache) flush() {
if c == nil {
return
}
c.mu.Lock()
c.epoch++
c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024)
c.mu.Unlock()
}
func (s *Service) InvalidatePrivateMediaCountReadModel(userID, peerID int64) {
if s == nil || s.privateMediaCountCache == nil || userID == 0 || peerID == 0 {
return
}
s.privateMediaCountCache.invalidate(privateMediaCountCacheKey{userID: userID, peerID: peerID})
}
func (s *Service) FlushPrivateMediaCountReadModel() {
if s == nil || s.privateMediaCountCache == nil {
return
}
s.privateMediaCountCache.flush()
}
func (c *privateMediaCountReadModelCache) cacheEpoch() uint64 {
c.mu.RLock()
epoch := c.epoch
c.mu.RUnlock()
return epoch
}
func clonePrivateMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts {
if len(in) == 0 {
return domain.MediaCategoryCounts{}
}
out := make(domain.MediaCategoryCounts, len(in))
for category, count := range in {
out[category] = count
}
return out
}

View file

@ -10,12 +10,32 @@ import (
// Service 提供消息历史、搜索与已读业务。
type Service struct {
messages store.MessageStore
dialogs store.DialogStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
projector *userprojection.Projector
messages store.MessageStore
dialogs store.DialogStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
versions store.ReadModelVersionStore
projector *userprojection.Projector
botResponder BotResponder
sendGate SendPermissionChecker
business *businessAutomationConfig
privateMediaCountCache *privateMediaCountReadModelCache
}
type SendPermissionChecker interface {
CanSendMessages(ctx context.Context, userID int64) error
}
// BotResponder 响应投递给服务端内置 bot(BotFather)的私聊消息。
// 实现方在用户消息已成功入库后被同步调用;回复失败只能记日志,
// 绝不允许影响用户消息发送结果。
type BotResponder interface {
// HandlesBot 报告 botUserID 是否为该 responder 负责的内置 bot。
HandlesBot(botUserID int64) bool
// OnPrivateMessage 处理一条投递给内置 bot 的消息;msg 为 bot 视角收件 box 行。
OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message)
}
// Option adjusts optional message service dependencies.
@ -36,9 +56,27 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
return func(s *Service) { s.privacy = p }
}
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
func WithBotResponder(r BotResponder) Option {
return func(s *Service) { s.botResponder = r }
}
func WithSendPermissionChecker(c SendPermissionChecker) Option {
return func(s *Service) { s.sendGate = c }
}
// WithReadModelVersions enables durable hash-token guarded media count caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 messages 服务。
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
s := &Service{messages: messages, dialogs: dialogs}
s := &Service{
messages: messages,
dialogs: dialogs,
privateMediaCountCache: newPrivateMediaCountReadModelCache(defaultPrivateMediaCountReadModelTTL),
}
for _, opt := range opts {
opt(s)
}
@ -58,7 +96,102 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
if req.SenderUserID == 0 {
req.SenderUserID = userID
}
return s.messages.SendPrivateText(ctx, req)
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
return domain.SendPrivateTextResult{}, err
}
automation, automationOK := s.prepareBusinessAutomation(ctx, req)
res, err := s.messages.SendPrivateText(ctx, req)
if err == nil && !res.Duplicate && automationOK {
s.runBusinessAutomation(ctx, req, res, automation)
}
// 内置 bot 应答:用户消息已提交(幂等重放除外)后同步触发;responder 自行
// 兜错,不回传失败。bot 自己发出的消息不触发(SenderUserID 不会是内置 bot
// 的对话对象集合里关心的方向——hook 只看收件人)。
if err == nil && !res.Duplicate && req.BusinessAutomationKind == "" && s.botResponder != nil && s.botResponder.HandlesBot(req.RecipientUserID) {
s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage)
}
return res, err
}
func (s *Service) ensureCanSend(ctx context.Context, userID int64) error {
if s == nil || s.sendGate == nil || userID == 0 {
return nil
}
return s.sendGate.CanSendMessages(ctx, userID)
}
// SetChatTheme updates the shared private-chat theme and records the timeline service message.
func (s *Service) SetChatTheme(ctx context.Context, userID int64, req domain.SetPrivateChatThemeRequest) (domain.SetPrivateChatThemeResult, error) {
out := domain.SetPrivateChatThemeResult{
OwnerUserID: userID,
Peer: req.Peer,
Emoticon: req.Emoticon,
}
if s == nil || s.messages == nil || s.dialogs == nil || userID == 0 {
return out, nil
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 {
return out, domain.ErrMessageIDInvalid
}
changedSelf, err := s.dialogs.SetChatTheme(ctx, userID, req.Peer, req.Emoticon)
if err != nil {
return out, err
}
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
changedPeer := false
if req.Peer.ID != userID && !req.RecipientBlocked {
changedPeer, err = s.dialogs.SetChatTheme(ctx, req.Peer.ID, otherPeer, req.Emoticon)
if err != nil {
return out, err
}
}
out.Changed = changedSelf || changedPeer
if !out.Changed {
return out, nil
}
send, err := s.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: req.Peer.ID,
RandomID: chatThemeServiceMessageRandomID(userID, req.Peer.ID, req.Emoticon, req.Date),
Media: chatThemeServiceMedia(req.Emoticon),
Silent: true,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
RecipientBlocked: req.RecipientBlocked,
})
if err != nil {
return out, err
}
out.Send = send
return out, nil
}
func chatThemeServiceMedia(emoticon string) *domain.MessageMedia {
return &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionSetChatTheme,
ChatThemeEmoticon: emoticon,
},
}
}
func chatThemeServiceMessageRandomID(userID, peerUserID int64, emoticon string, date int) int64 {
var id int64 = 0x43485448454d45
id ^= userID << 21
id ^= peerUserID << 7
id ^= int64(date) << 33
for _, r := range emoticon {
id = (id << 5) - id + int64(r)
}
if id == 0 {
return 0x43485401
}
return id
}
// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。
@ -69,6 +202,9 @@ func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if err := s.ensureCanSend(ctx, req.OwnerUserID); err != nil {
return domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID}, err
}
return s.messages.ForwardPrivateMessages(ctx, req)
}
@ -94,6 +230,22 @@ func (s *Service) Search(ctx context.Context, userID int64, filter domain.Messag
return s.list(ctx, userID, filter)
}
// SearchPrivateMedia 返回某私聊会话中属于给定媒体类别的消息(共享媒体标签页)。
func (s *Service) SearchPrivateMedia(ctx context.Context, userID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) {
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
return domain.MessageList{}, nil
}
return s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
}
// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。
func (s *Service) CountPrivateMediaCategories(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) {
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
return domain.MediaCategoryCounts{}, nil
}
return s.cachedPrivateMediaCounts(ctx, userID, peerID)
}
// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。
func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
if s == nil || userID == 0 {
@ -156,6 +308,34 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
return s.messages.SetMessageReactions(ctx, req)
}
// VoteMessagePoll 给私聊消息上的 poll 投票(options 为空 = 撤票)。
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.PrivateMessagePollResult{}, 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.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
}
return s.messages.VoteMessagePoll(ctx, req)
}
// CloseMessagePoll 关闭私聊消息上的 poll(仅 poll 创建者)。
func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.PrivateMessagePollResult{}, 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.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
}
return s.messages.CloseMessagePoll(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 {
@ -186,6 +366,35 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
return s.messages.EditMessage(ctx, req)
}
// PinPrivateMessage 翻转当前账号可见私聊消息的置顶状态;非 pm_oneside
// 时同步翻转对端视角。
func (s *Service) PinPrivateMessage(ctx context.Context, userID int64, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if req.OwnerUserID != userID {
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
}
return s.messages.PinPrivateMessage(ctx, req)
}
// UnpinAllPrivateMessages 清空当前账号与某私聊 peer 的全部置顶。
func (s *Service) UnpinAllPrivateMessages(ctx context.Context, userID int64, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if req.OwnerUserID != userID {
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
}
return s.messages.UnpinAllPrivateMessages(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 {
@ -208,6 +417,240 @@ func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.De
return s.messages.DeleteHistory(ctx, req)
}
// GetSavedDialogs 返回收藏夹子会话分页(messages.getSavedDialogs)。
func (s *Service) GetSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.SavedDialogList{Full: true}, nil
}
return s.messages.ListSavedDialogs(ctx, userID, filter)
}
// GetPinnedSavedDialogs 返回全部置顶收藏夹子会话(messages.getPinnedSavedDialogs)。
func (s *Service) GetPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.SavedDialogList{Full: true}, nil
}
return s.messages.ListPinnedSavedDialogs(ctx, userID)
}
// GetSavedDialogsByPeers 返回指定收藏夹子会话(messages.getSavedDialogsByID)。
func (s *Service) GetSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) {
if s == nil || s.messages == nil || userID == 0 || len(peers) == 0 {
return domain.SavedDialogList{Full: true}, nil
}
return s.messages.ListSavedDialogsByPeers(ctx, userID, peers)
}
// ToggleSavedDialogPin 翻转收藏夹子会话置顶状态,返回是否实际变化。
func (s *Service) ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
if s == nil || s.messages == nil || userID == 0 {
return false, nil
}
return s.messages.ToggleSavedDialogPin(ctx, userID, peer, pinned)
}
// ReorderPinnedSavedDialogs 全量重排收藏夹置顶顺序。
func (s *Service) ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
if s == nil || s.messages == nil || userID == 0 {
return nil
}
return s.messages.ReorderPinnedSavedDialogs(ctx, userID, order, force)
}
// DeleteSavedHistory 删除收藏夹一个子会话的消息(单批)。
func (s *Service) DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.DeleteSavedHistoryResult{}, nil
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
return s.messages.DeleteSavedHistory(ctx, req)
}
func (s *Service) ScheduleMessage(ctx context.Context, userID int64, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.ScheduledMessage{}, nil
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return domain.ScheduledMessage{}, nil
}
return scheduled.CreateScheduledMessage(ctx, req)
}
func (s *Service) ListScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.ScheduledMessageList{}, nil
}
if filter.OwnerUserID == 0 {
filter.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return domain.ScheduledMessageList{}, nil
}
return scheduled.ListScheduledMessages(ctx, filter)
}
func (s *Service) EditScheduledMessage(ctx context.Context, userID int64, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.ScheduledMessage{}, nil
}
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return domain.ScheduledMessage{}, nil
}
return scheduled.EditScheduledMessage(ctx, req)
}
func (s *Service) GetScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.ScheduledMessageList{}, nil
}
if filter.OwnerUserID == 0 {
filter.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return domain.ScheduledMessageList{}, nil
}
return scheduled.GetScheduledMessages(ctx, filter)
}
func (s *Service) DeleteScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) {
if s == nil || s.messages == nil || userID == 0 {
return nil, nil
}
if filter.OwnerUserID == 0 {
filter.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return nil, nil
}
return scheduled.DeleteScheduledMessages(ctx, filter, date)
}
func (s *Service) ClaimScheduledMessages(ctx context.Context, userID int64, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) {
if s == nil || s.messages == nil || userID == 0 {
return nil, nil
}
if claim.OwnerUserID == 0 {
claim.OwnerUserID = userID
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return nil, nil
}
return scheduled.ClaimScheduledMessages(ctx, claim)
}
func (s *Service) ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) {
if s == nil || s.messages == nil {
return nil, nil
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return nil, nil
}
return scheduled.ClaimDueScheduledMessages(ctx, now, limit, leaseSeconds)
}
func (s *Service) MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error {
if s == nil || s.messages == nil {
return nil
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return nil
}
return scheduled.MarkScheduledMessageSent(ctx, ownerUserID, id, sentMessageID, date)
}
func (s *Service) ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error {
if s == nil || s.messages == nil {
return nil
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return nil
}
return scheduled.ReleaseScheduledMessage(ctx, ownerUserID, id, errText)
}
func (s *Service) HasScheduledMessages(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
if s == nil || s.messages == nil || userID == 0 {
return false, nil
}
scheduled, ok := s.messages.(store.ScheduledMessageStore)
if !ok {
return false, nil
}
return scheduled.HasScheduledMessages(ctx, userID, peer)
}
func (s *Service) GetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer) (int, error) {
if s == nil || s.messages == nil || userID == 0 {
return 0, nil
}
ttl, ok := s.messages.(store.HistoryTTLStore)
if !ok {
return 0, nil
}
return ttl.GetPrivateHistoryTTL(ctx, userID, peer)
}
func (s *Service) SetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer, period int) error {
if s == nil || s.messages == nil || userID == 0 {
return nil
}
ttl, ok := s.messages.(store.HistoryTTLStore)
if !ok {
return nil
}
return ttl.SetPrivateHistoryTTL(ctx, userID, peer, period)
}
func (s *Service) DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) {
if s == nil || s.messages == nil || userID == 0 {
return 0, nil
}
ttl, ok := s.messages.(store.HistoryTTLStore)
if !ok {
return 0, nil
}
return ttl.DefaultHistoryTTL(ctx, userID)
}
func (s *Service) SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error {
if s == nil || s.messages == nil || userID == 0 {
return nil
}
ttl, ok := s.messages.(store.HistoryTTLStore)
if !ok {
return nil
}
return ttl.SetDefaultHistoryTTL(ctx, userID, period)
}
func (s *Service) ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) {
if s == nil || s.messages == nil {
return nil, nil
}
ttl, ok := s.messages.(store.HistoryTTLStore)
if !ok {
return nil, nil
}
return ttl.ClaimExpiredPrivateMessages(ctx, now, limit)
}
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
@ -230,3 +673,26 @@ func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list do
list.Users = users
return list, nil
}
type privateUnreadReactionsStore interface {
ListUnreadReactionMessages(ctx context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error)
ReadPeerReactions(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error)
}
// ListUnreadReactionMessages 返回私聊 peer 下带未读 reaction 的消息。
func (s *Service) ListUnreadReactionMessages(ctx context.Context, userID int64, peer domain.Peer, limit int) ([]domain.Message, error) {
store, ok := s.messages.(privateUnreadReactionsStore)
if !ok || userID == 0 {
return nil, nil
}
return store.ListUnreadReactionMessages(ctx, userID, peer, limit)
}
// ReadPeerReactions 清理私聊 peer 下的全部未读 reaction。
func (s *Service) ReadPeerReactions(ctx context.Context, userID int64, peer domain.Peer) (int, error) {
store, ok := s.messages.(privateUnreadReactionsStore)
if !ok || userID == 0 {
return 0, nil
}
return store.ReadPeerReactions(ctx, userID, peer)
}

View file

@ -2,12 +2,51 @@ package messages
import (
"context"
"errors"
"testing"
"telesrv/internal/app/account"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
ctx := context.Background()
store := &gateMessageStore{}
svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{}))
if _, err := svc.SendPrivateText(ctx, 1001, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
}
if store.sends != 0 {
t.Fatalf("store sends=%d, want 0", store.sends)
}
}
func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
ctx := context.Background()
store := &gateMessageStore{}
svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{}))
if _, err := svc.ForwardPrivateMessages(ctx, 1001, domain.ForwardPrivateMessagesRequest{
OwnerUserID: 1001,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
ToUserID: 1003,
MessageIDs: []int{1},
RandomIDs: []int64{2},
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
}
if store.forwards != 0 {
t.Fatalf("store forwards=%d, want 0", store.forwards)
}
}
func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
@ -58,6 +97,281 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
}
}
func TestBusinessAutomationGreetingSendsQuickReplyWithoutLoop(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2001
const customerID int64 = 2002
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := memory.NewPasswordStore()
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
ownerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "owner hello", 100)
customerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, customerID, "hello", "customer hello", 101)
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
ShortcutID: ownerShortcutID,
Recipients: businessAutomationAllRecipients(),
NoActivityDays: 7,
}); err != nil {
t.Fatalf("update owner greeting: %v", err)
}
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, customerID, &domain.BusinessGreetingMessage{
ShortcutID: customerShortcutID,
Recipients: businessAutomationAllRecipients(),
NoActivityDays: 7,
}); err != nil {
t.Fatalf("update customer greeting: %v", err)
}
svc := NewService(messages, dialogs, WithBusinessAutomation(business))
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 10001,
Message: "hi",
Date: 1_700_000_000,
}); err != nil {
t.Fatalf("send first message: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 {
t.Fatalf("customer owner hello count = %d, want 1", got)
}
if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "owner hello"); got != 1 {
t.Fatalf("owner outgoing hello count = %d, want 1", got)
}
if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "customer hello"); got != 0 {
t.Fatalf("recursive customer hello count = %d, want 0", got)
}
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 10002,
Message: "again",
Date: 1_700_000_060,
}); err != nil {
t.Fatalf("send second message: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 {
t.Fatalf("owner hello after second incoming = %d, want 1", got)
}
}
func TestBusinessAutomationAwayHonorsOnlineStateAndCooldown(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2101
const customerID int64 = 2102
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := memory.NewPasswordStore()
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "away", "away reply", 200)
if _, err := accountSvc.UpdateBusinessAwayMessage(ctx, ownerID, &domain.BusinessAwayMessage{
ShortcutID: shortcutID,
Schedule: domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleAlways},
Recipients: businessAutomationAllRecipients(),
OfflineOnly: true,
}); err != nil {
t.Fatalf("update away: %v", err)
}
online := businessAutomationOnline{ownerID: true}
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationOnlineChecker(online)))
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 20001,
Message: "online?",
Date: 1_700_010_000,
}); err != nil {
t.Fatalf("send while online: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 0 {
t.Fatalf("away while online count = %d, want 0", got)
}
online[ownerID] = false
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 20002,
Message: "offline?",
Date: 1_700_010_060,
}); err != nil {
t.Fatalf("send while offline: %v", err)
}
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 20003,
Message: "still offline?",
Date: 1_700_010_120,
}); err != nil {
t.Fatalf("send during cooldown: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 1 {
t.Fatalf("away reply count = %d, want 1", got)
}
}
func TestBusinessAutomationReplyProviderCanReplaceTemplate(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2201
const customerID int64 = 2202
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := memory.NewPasswordStore()
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 300)
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
ShortcutID: shortcutID,
Recipients: businessAutomationAllRecipients(),
NoActivityDays: 7,
}); err != nil {
t.Fatalf("update greeting: %v", err)
}
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(staticBusinessAutomationProvider{message: "ai reply"})))
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 30001,
Message: "hi",
Date: 1_700_020_000,
}); err != nil {
t.Fatalf("send first message: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "ai reply"); got != 1 {
t.Fatalf("provider reply count = %d, want 1", got)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 {
t.Fatalf("template reply count = %d, want 0", got)
}
}
func TestBusinessAutomationEchoProviderEchoesTriggerText(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2301
const customerID int64 = 2302
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := memory.NewPasswordStore()
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 400)
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
ShortcutID: shortcutID,
Recipients: businessAutomationAllRecipients(),
NoActivityDays: 7,
}); err != nil {
t.Fatalf("update greeting: %v", err)
}
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider())))
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 40001,
Message: "echo this",
Date: 1_700_030_000,
}); err != nil {
t.Fatalf("send first message: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "echo this"); got != 2 {
t.Fatalf("echo body count in customer history = %d, want 2 (outgoing + echo reply)", got)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 {
t.Fatalf("template reply count = %d, want 0", got)
}
}
func TestConnectedBusinessBotEchoHonorsPauseAndDisable(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2401
const customerID int64 = 2402
const botID int64 = 2403
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := memory.NewPasswordStore()
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
if _, err := accountSvc.SaveConnectedBusinessBot(ctx, ownerID, domain.ConnectedBusinessBot{
BotUserID: botID,
Recipients: domain.BusinessBotRecipients{ExcludeSelected: true},
Rights: domain.BusinessBotRights{Reply: true},
}); err != nil {
t.Fatalf("save connected bot: %v", err)
}
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider())))
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 50001,
Message: "connected echo",
Date: 1_700_050_000,
}); err != nil {
t.Fatalf("send connected echo: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "connected echo"); got != 2 {
t.Fatalf("connected echo customer count = %d, want 2 (original + echo)", got)
}
assertMessageViaBot(t, ctx, messages, customerID, ownerID, "connected echo", botID)
if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, true); err != nil {
t.Fatalf("pause connected bot: %v", err)
}
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 50002,
Message: "paused echo",
Date: 1_700_050_060,
}); err != nil {
t.Fatalf("send paused echo: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "paused echo"); got != 1 {
t.Fatalf("paused echo customer count = %d, want 1", got)
}
if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, false); err != nil {
t.Fatalf("unpause connected bot: %v", err)
}
if _, err := accountSvc.DisableConnectedBusinessBotForPeer(ctx, ownerID, customerID); err != nil {
t.Fatalf("disable connected bot peer: %v", err)
}
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 50003,
Message: "disabled echo",
Date: 1_700_050_120,
}); err != nil {
t.Fatalf("send disabled echo: %v", err)
}
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "disabled echo"); got != 1 {
t.Fatalf("disabled echo customer count = %d, want 1", got)
}
}
func TestEchoBusinessAutomationProviderSkipsEmptyText(t *testing.T) {
msgs, err := NewEchoBusinessAutomationProvider().BusinessAutomationReplies(context.Background(), BusinessAutomationReplyInput{
TriggerMessage: domain.Message{Body: " \t\n"},
Now: 1_700_040_000,
})
if err != nil {
t.Fatalf("BusinessAutomationReplies: %v", err)
}
if len(msgs) != 0 {
t.Fatalf("messages = %+v, want none", msgs)
}
}
func findUser(t *testing.T, users []domain.User, id int64) domain.User {
t.Helper()
for _, user := range users {
@ -69,12 +383,191 @@ func findUser(t *testing.T, users []domain.User, id int64) domain.User {
return domain.User{}
}
func TestCountPrivateMediaCategoriesCachesByReadModelHash(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
const peerID int64 = 1002
key := store.ReadModelKey{Model: readmodel.ModelPrivateMediaCounts, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: peerID}
versions := &fakeMessageReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 101}}
counting := &countingPrivateMediaStore{counts: domain.MediaCategoryCounts{
domain.MediaCategoryPhoto: 3,
domain.MediaCategoryVideo: 2,
}}
svc := NewService(counting, nil, WithReadModelVersions(versions))
first, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
if err != nil {
t.Fatalf("CountPrivateMediaCategories first: %v", err)
}
first[domain.MediaCategoryPhoto] = 99
second, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
if err != nil {
t.Fatalf("CountPrivateMediaCategories second: %v", err)
}
if counting.countPrivateMediaCalls != 1 {
t.Fatalf("count calls = %d, want 1", counting.countPrivateMediaCalls)
}
if got := second[domain.MediaCategoryPhoto]; got != 3 {
t.Fatalf("cached photo count = %d, want 3", got)
}
svc.InvalidatePrivateMediaCountReadModel(ownerID, peerID)
if _, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID); err != nil {
t.Fatalf("CountPrivateMediaCategories after explicit invalidation: %v", err)
}
if counting.countPrivateMediaCalls != 2 {
t.Fatalf("count calls after explicit invalidation = %d, want 2", counting.countPrivateMediaCalls)
}
versions.hashes[key] = 202
counting.counts[domain.MediaCategoryPhoto] = 4
third, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
if err != nil {
t.Fatalf("CountPrivateMediaCategories third: %v", err)
}
if counting.countPrivateMediaCalls != 3 {
t.Fatalf("count calls after hash change = %d, want 3", counting.countPrivateMediaCalls)
}
if got := third[domain.MediaCategoryPhoto]; got != 4 {
t.Fatalf("reloaded photo count = %d, want 4", got)
}
}
type projectionMessageStore struct {
list domain.MessageList
}
type denySendChecker struct{}
func (denySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
}
type gateMessageStore struct {
projectionMessageStore
sends int
forwards int
}
func (s *gateMessageStore) SendPrivateText(context.Context, domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
s.sends++
return domain.SendPrivateTextResult{}, nil
}
func (s *gateMessageStore) ForwardPrivateMessages(context.Context, domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
s.forwards++
return domain.ForwardPrivateMessagesResult{}, nil
}
type countingPrivateMediaStore struct {
projectionMessageStore
counts domain.MediaCategoryCounts
countPrivateMediaCalls int
}
func (s *countingPrivateMediaStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
s.countPrivateMediaCalls++
out := make(domain.MediaCategoryCounts, len(s.counts))
for category, count := range s.counts {
out[category] = count
}
return out, nil
}
type fakeMessageReadModelVersions struct {
hashes map[store.ReadModelKey]int64
}
func (f *fakeMessageReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
return hash, hash != 0, nil
}
func (f *fakeMessageReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
if hash := f.hashes[key]; hash != 0 {
out[key] = hash
}
}
return out, nil
}
type messageProfilePhotos map[int64]domain.ProfilePhotoRef
type businessAutomationOnline map[int64]bool
func (o businessAutomationOnline) IsUserOnline(userID int64) bool {
return o[userID]
}
type staticBusinessAutomationProvider struct {
message string
}
func (p staticBusinessAutomationProvider) BusinessAutomationReplies(context.Context, BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
return []domain.QuickReplyMessage{{ID: 1, Message: p.message}}, nil
}
func businessAutomationAllRecipients() domain.BusinessRecipients {
return domain.BusinessRecipients{
ExistingChats: true,
NewChats: true,
Contacts: true,
NonContacts: true,
}
}
func saveBusinessQuickReply(t *testing.T, ctx context.Context, svc *account.Service, ownerID int64, shortcut, message string, randomID int64) int {
t.Helper()
mutation, err := svc.SaveQuickReplyText(ctx, ownerID, shortcut, domain.QuickReplyMessage{
RandomID: randomID,
Date: 1_700_000_000,
Message: message,
})
if err != nil {
t.Fatalf("save quick reply %s: %v", shortcut, err)
}
return mutation.ShortcutID
}
func countBusinessMessagesByBody(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string) int {
t.Helper()
list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
Limit: 50,
})
if err != nil {
t.Fatalf("list messages: %v", err)
}
count := 0
for _, msg := range list.Messages {
if msg.Body == body {
count++
}
}
return count
}
func assertMessageViaBot(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string, botID int64) {
t.Helper()
list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
Limit: 50,
})
if err != nil {
t.Fatalf("list messages: %v", err)
}
for _, msg := range list.Messages {
if msg.Body == body && msg.ViaBotID == botID {
return
}
}
t.Fatalf("message body %q via bot %d not found in %+v", body, botID, list.Messages)
}
func (p messageProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
for _, id := range ids {
@ -117,14 +610,54 @@ func (s projectionMessageStore) GetMessageReactions(context.Context, domain.Priv
return domain.PrivateMessageReactionsResult{}, nil
}
func (s projectionMessageStore) VoteMessagePoll(context.Context, domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
return domain.PrivateMessagePollResult{}, nil
}
func (s projectionMessageStore) CloseMessagePoll(context.Context, domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
return domain.PrivateMessagePollResult{}, nil
}
func (s projectionMessageStore) EditMessage(context.Context, domain.EditMessageRequest) (domain.EditMessageResult, error) {
return domain.EditMessageResult{}, nil
}
func (s projectionMessageStore) PinPrivateMessage(context.Context, domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) {
return domain.PinPrivateMessageResult{}, nil
}
func (s projectionMessageStore) UnpinAllPrivateMessages(context.Context, domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) {
return domain.PinPrivateMessageResult{}, nil
}
func (s projectionMessageStore) DeleteMessages(context.Context, domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
return domain.DeleteMessagesResult{}, nil
}
func (s projectionMessageStore) ListSavedDialogs(context.Context, int64, domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
return domain.SavedDialogList{}, nil
}
func (s projectionMessageStore) ListPinnedSavedDialogs(context.Context, int64) (domain.SavedDialogList, error) {
return domain.SavedDialogList{}, nil
}
func (s projectionMessageStore) ListSavedDialogsByPeers(context.Context, int64, []domain.Peer) (domain.SavedDialogList, error) {
return domain.SavedDialogList{}, nil
}
func (s projectionMessageStore) ToggleSavedDialogPin(context.Context, int64, domain.Peer, bool) (bool, error) {
return false, nil
}
func (s projectionMessageStore) ReorderPinnedSavedDialogs(context.Context, int64, []domain.Peer, bool) error {
return nil
}
func (s projectionMessageStore) DeleteSavedHistory(context.Context, domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
return domain.DeleteSavedHistoryResult{}, nil
}
func (s projectionMessageStore) DeleteHistory(context.Context, domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
return domain.DeleteMessagesResult{}, nil
}
@ -136,3 +669,11 @@ func (s projectionMessageStore) GetByIDs(context.Context, int64, []int) (domain.
func (s projectionMessageStore) ListByUser(context.Context, int64, domain.MessageFilter) (domain.MessageList, error) {
return s.list, nil
}
func (s projectionMessageStore) SearchPrivateMedia(context.Context, int64, int64, domain.MediaSearchRequest) (domain.MessageList, error) {
return domain.MessageList{}, nil
}
func (s projectionMessageStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
return domain.MediaCategoryCounts{}, nil
}

View file

@ -0,0 +1,274 @@
// Package passkey 实现 passkey(WebAuthn)登录与管理的业务编排:生成挑战选项、
// 验证注册 attestation、验证登录 assertion,并维护凭据/挑战持久化。
// 密码学验证委托 internal/webauthn;auth_key 绑定委托 auth.Service。
package passkey
import (
"context"
"crypto/rand"
"fmt"
"strconv"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/webauthn"
)
const (
defaultChallengeTTL = 5 * time.Minute
challengeSize = 32
)
// Service 提供 passkey 登录/注册业务。
type Service struct {
creds store.PasskeyStore
challenges store.PasskeyChallengeStore
rpID string
rpName string
allowedOrigins []string
dcID int
challengeTTL time.Duration
now func() time.Time
}
// Option 调整 passkey 服务可选项。
type Option func(*Service)
// WithRPName 设置 relying-party 显示名(authenticator UI 展示)。
func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } }
// WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin
//(服务端通常不预知 Android apk-key-hash origin)。
func WithAllowedOrigins(origins []string) Option {
return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) }
}
// WithChallengeTTL 覆盖挑战有效期。
func WithChallengeTTL(d time.Duration) Option {
return func(s *Service) {
if d > 0 {
s.challengeTTL = d
}
}
}
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// NewService 创建 passkey 服务。rpID 为 WebAuthn relying-party id(域名);dcID 写入
// user_handle 供客户端 DC 重路由(本部署单 DC,仅做回显)。
func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore, rpID string, dcID int, opts ...Option) *Service {
s := &Service{
creds: creds,
challenges: challenges,
rpID: rpID,
rpName: "Telegram",
dcID: dcID,
challengeTTL: defaultChallengeTTL,
now: time.Now,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) randomChallenge() ([]byte, error) {
b := make([]byte, challengeSize)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return b, nil
}
func (s *Service) userHandle(userID int64) string {
return fmt.Sprintf("%d:%d", s.dcID, userID)
}
func parseUserHandle(h string) (int64, error) {
h = strings.TrimSpace(h)
if h == "" {
return 0, domain.ErrPasskeyUserHandleInvalid
}
if i := strings.LastIndex(h, ":"); i >= 0 {
h = h[i+1:]
}
id, err := strconv.ParseInt(h, 10, 64)
if err != nil || id == 0 {
return 0, domain.ErrPasskeyUserHandleInvalid
}
return id, nil
}
// InitRegistration 为已登录用户生成注册选项(creation options DataJSON)。
func (s *Service) InitRegistration(ctx context.Context, userID int64, displayName string) ([]byte, error) {
if s == nil || s.creds == nil || s.challenges == nil || userID == 0 {
return nil, domain.ErrPasskeyInvalid
}
challenge, err := s.randomChallenge()
if err != nil {
return nil, err
}
if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{
Purpose: domain.PasskeyChallengeRegister,
UserID: userID,
ExpiresAt: s.now().Add(s.challengeTTL).Unix(),
}, s.challengeTTL); err != nil {
return nil, err
}
existing, err := s.creds.ListPasskeysByUser(ctx, userID)
if err != nil {
return nil, err
}
exclude := make([][]byte, 0, len(existing))
for _, c := range existing {
exclude = append(exclude, c.CredentialID)
}
if displayName == "" {
displayName = fmt.Sprintf("user%d", userID)
}
return webauthn.BuildRegistrationOptions(webauthn.RegistrationParams{
RPID: s.rpID,
RPName: s.rpName,
UserID: []byte(s.userHandle(userID)),
UserName: displayName,
UserDisplay: displayName,
Challenge: challenge,
ExcludeIDs: exclude,
})
}
// Register 验证注册 attestation 并持久化凭据。credentialID 为原始字节(rpc 已 base64url 解码)。
func (s *Service) Register(ctx context.Context, userID int64, credentialID, clientDataJSON, attestationObject []byte, name string) (domain.PasskeyCredential, error) {
if s == nil || s.creds == nil || s.challenges == nil || userID == 0 {
return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid
}
challenge, err := webauthn.ChallengeFromClientData(clientDataJSON)
if err != nil {
return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid
}
meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge)
if err != nil {
return domain.PasskeyCredential{}, err
}
if !found || meta.Purpose != domain.PasskeyChallengeRegister || meta.UserID != userID {
return domain.PasskeyCredential{}, domain.ErrPasskeyChallengeInvalid
}
cred, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{
ClientDataJSON: clientDataJSON,
AttestationObject: attestationObject,
RPID: s.rpID,
Challenge: challenge,
AllowedOrigins: s.allowedOrigins,
})
if err != nil {
return domain.PasskeyCredential{}, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err)
}
// credentialID 来自 TL 的 ID/RawID;以 authData 内的 credID 为准(防客户端不一致)。
now := s.now().Unix()
record := domain.PasskeyCredential{
CredentialID: cred.ID,
UserID: userID,
PublicKey: cred.COSEPublicKey,
SignCount: cred.SignCount,
AAGUID: cred.AAGUID,
Name: strings.TrimSpace(name),
CreatedAt: now,
}
if err := s.creds.InsertPasskey(ctx, record); err != nil {
return domain.PasskeyCredential{}, err
}
return record, nil
}
// InitLogin 生成登录选项(request options DataJSON,discoverable,无 allowCredentials)。
func (s *Service) InitLogin(ctx context.Context) ([]byte, error) {
if s == nil || s.challenges == nil {
return nil, domain.ErrPasskeyInvalid
}
challenge, err := s.randomChallenge()
if err != nil {
return nil, err
}
if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{
Purpose: domain.PasskeyChallengeLogin,
ExpiresAt: s.now().Add(s.challengeTTL).Unix(),
}, s.challengeTTL); err != nil {
return nil, err
}
return webauthn.BuildLoginOptions(webauthn.LoginParams{RPID: s.rpID, Challenge: challenge})
}
// FinishLogin 验证登录 assertion,成功返回该 passkey 所属用户 id(auth_key 绑定由调用方完成)。
func (s *Service) FinishLogin(ctx context.Context, credentialID, clientDataJSON, authenticatorData, signature []byte, userHandle string) (int64, error) {
if s == nil || s.creds == nil || s.challenges == nil {
return 0, domain.ErrPasskeyInvalid
}
handleUserID, err := parseUserHandle(userHandle)
if err != nil {
return 0, err
}
cred, found, err := s.creds.GetPasskeyByCredentialID(ctx, credentialID)
if err != nil {
return 0, err
}
if !found {
return 0, domain.ErrPasskeyNotFound
}
// 交叉校验:credential 所属用户须与 user_handle 一致。
if cred.UserID != handleUserID {
return 0, domain.ErrPasskeyInvalid
}
challenge, err := webauthn.ChallengeFromClientData(clientDataJSON)
if err != nil {
return 0, domain.ErrPasskeyInvalid
}
meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge)
if err != nil {
return 0, err
}
if !found || meta.Purpose != domain.PasskeyChallengeLogin {
return 0, domain.ErrPasskeyChallengeInvalid
}
newCount, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
COSEPublicKey: cred.PublicKey,
ClientDataJSON: clientDataJSON,
AuthenticatorData: authenticatorData,
Signature: signature,
RPID: s.rpID,
Challenge: challenge,
AllowedOrigins: s.allowedOrigins,
StoredSignCount: cred.SignCount,
})
if err != nil {
return 0, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err)
}
if err := s.creds.UpdatePasskeyUsage(ctx, cred.CredentialID, newCount, s.now().Unix()); err != nil {
return 0, err
}
return cred.UserID, nil
}
// List 返回用户的全部 passkey(管理页)。
func (s *Service) List(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) {
if s == nil || s.creds == nil || userID == 0 {
return nil, nil
}
return s.creds.ListPasskeysByUser(ctx, userID)
}
// Delete 删除用户的某个 passkey。credentialID 为原始字节。
func (s *Service) Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error) {
if s == nil || s.creds == nil || userID == 0 {
return false, nil
}
return s.creds.DeletePasskey(ctx, userID, credentialID)
}

View file

@ -0,0 +1,141 @@
package peerview
import (
"context"
"telesrv/internal/domain"
)
// UserResolver resolves users after applying viewer-specific projection.
type UserResolver interface {
ByIDs(ctx context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error)
}
// BatchCache caches projected users only for one update-building batch.
// The cache key includes viewerUserID because contacts, privacy, phone visibility
// and personal/fallback photos are viewer-specific.
type BatchCache struct {
users UserResolver
byViewer map[int64]map[int64]domain.User
missing map[int64]map[int64]struct{}
}
func NewBatchCache(users UserResolver) *BatchCache {
return &BatchCache{
users: users,
byViewer: make(map[int64]map[int64]domain.User),
missing: make(map[int64]map[int64]struct{}),
}
}
func (c *BatchCache) UsersForView(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
unique := uniqueIDs(ids)
if len(unique) == 0 {
return nil, nil
}
byID := c.viewerUsers(viewerUserID)
missing := c.viewerMissing(viewerUserID)
load := make([]int64, 0, len(unique))
for _, id := range unique {
if _, ok := byID[id]; ok {
continue
}
if _, ok := missing[id]; ok {
continue
}
if u, ok := domain.SystemUserByID(id); ok {
byID[id] = u
continue
}
if c.users == nil || viewerUserID == 0 {
missing[id] = struct{}{}
continue
}
load = append(load, id)
}
var err error
if len(load) > 0 {
var resolved []domain.User
resolved, err = c.users.ByIDs(ctx, viewerUserID, load)
if err == nil {
found := make(map[int64]struct{}, len(resolved))
for _, u := range resolved {
if u.ID == 0 {
continue
}
byID[u.ID] = u
found[u.ID] = struct{}{}
}
for _, id := range load {
if _, ok := found[id]; !ok {
missing[id] = struct{}{}
}
}
}
}
out := make([]domain.User, 0, len(unique))
for _, id := range unique {
if u, ok := byID[id]; ok {
out = append(out, u)
}
}
return out, err
}
// Prime 预热某 viewer 的已投影用户(fan-out 跨 viewer 模板化把 per-recipient ForViewer 折叠成
// 一次 ForViewers 后回填)。已存在的 id 不覆盖(保留同批已解析结果),避免预热与按需解析互相打架。
// 预热的是「投影后、未叠加实时 presence」的用户——与 UsersForView 缓存层一致,presence 仍由
// 上层在输出端叠加,故预热不破坏 presence 新鲜度。
func (c *BatchCache) Prime(viewerUserID int64, users []domain.User) {
if c == nil || viewerUserID == 0 || len(users) == 0 {
return
}
byID := c.viewerUsers(viewerUserID)
for _, u := range users {
if u.ID == 0 {
continue
}
if _, ok := byID[u.ID]; ok {
continue
}
byID[u.ID] = u
}
}
func (c *BatchCache) viewerUsers(viewerUserID int64) map[int64]domain.User {
if byID, ok := c.byViewer[viewerUserID]; ok {
return byID
}
byID := make(map[int64]domain.User)
c.byViewer[viewerUserID] = byID
return byID
}
func (c *BatchCache) viewerMissing(viewerUserID int64) map[int64]struct{} {
if missing, ok := c.missing[viewerUserID]; ok {
return missing
}
missing := make(map[int64]struct{})
c.missing[viewerUserID] = missing
return missing
}
func uniqueIDs(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -0,0 +1,108 @@
package peerview
import (
"context"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestBatchCacheCachesPerViewer(t *testing.T) {
resolver := &captureUserResolver{
users: map[int64]domain.User{
1000000001: {ID: 1000000001, FirstName: "Alice"},
},
}
cache := NewBatchCache(resolver)
got, err := cache.UsersForView(context.Background(), 1000000002, []int64{1000000001, 1000000001})
if err != nil {
t.Fatalf("UsersForView: %v", err)
}
if len(got) != 1 || got[0].ID != 1000000001 {
t.Fatalf("users = %+v, want Alice once", got)
}
got, err = cache.UsersForView(context.Background(), 1000000002, []int64{1000000001})
if err != nil {
t.Fatalf("UsersForView cached: %v", err)
}
got, err = cache.UsersForView(context.Background(), 1000000003, []int64{1000000001})
if err != nil {
t.Fatalf("UsersForView other viewer: %v", err)
}
wantCalls := []resolverCall{
{viewerUserID: 1000000002, ids: []int64{1000000001}},
{viewerUserID: 1000000003, ids: []int64{1000000001}},
}
if !reflect.DeepEqual(resolver.calls, wantCalls) {
t.Fatalf("resolver calls = %+v, want %+v", resolver.calls, wantCalls)
}
}
// TestBatchCachePrimeServesWithoutResolver:Prime 预热的 viewer 用户被 UsersForView 直接命中,
// 不再回落 resolver(fan-out 跨 viewer 投影预热把 per-recipient ByIDs 折叠成一次 ForViewers 的前提);
// 且 Prime 不覆盖已解析的同 id(按需解析优先)。
func TestBatchCachePrimeServesWithoutResolver(t *testing.T) {
resolver := &captureUserResolver{
users: map[int64]domain.User{
1000000001: {ID: 1000000001, FirstName: "Resolved"},
},
}
cache := NewBatchCache(resolver)
const viewer = int64(1000000002)
cache.Prime(viewer, []domain.User{{ID: 1000000001, FirstName: "Primed"}, {ID: 1000000009, FirstName: "PrimedOnly"}})
got, err := cache.UsersForView(context.Background(), viewer, []int64{1000000001, 1000000009})
if err != nil {
t.Fatalf("UsersForView: %v", err)
}
byID := map[int64]domain.User{}
for _, u := range got {
byID[u.ID] = u
}
if byID[1000000001].FirstName != "Primed" || byID[1000000009].FirstName != "PrimedOnly" {
t.Fatalf("primed users = %+v, want Primed/PrimedOnly served from cache", got)
}
if len(resolver.calls) != 0 {
t.Fatalf("resolver called %d times, want 0 (all served from prime)", len(resolver.calls))
}
// 已解析的 id 不被后续 Prime 覆盖。
other := &captureUserResolver{users: map[int64]domain.User{1000000003: {ID: 1000000003, FirstName: "Resolved3"}}}
c2 := NewBatchCache(other)
if _, err := c2.UsersForView(context.Background(), viewer, []int64{1000000003}); err != nil {
t.Fatalf("resolve 3: %v", err)
}
c2.Prime(viewer, []domain.User{{ID: 1000000003, FirstName: "ShouldNotOverwrite"}})
got2, err := c2.UsersForView(context.Background(), viewer, []int64{1000000003})
if err != nil {
t.Fatalf("UsersForView after prime: %v", err)
}
if len(got2) != 1 || got2[0].FirstName != "Resolved3" {
t.Fatalf("after prime = %+v, want resolved value preserved (no overwrite)", got2)
}
}
type resolverCall struct {
viewerUserID int64
ids []int64
}
type captureUserResolver struct {
users map[int64]domain.User
calls []resolverCall
}
func (r *captureUserResolver) ByIDs(_ context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) {
r.calls = append(r.calls, resolverCall{viewerUserID: viewerUserID, ids: append([]int64(nil), userIDs...)})
out := make([]domain.User, 0, len(userIDs))
for _, id := range userIDs {
if u, ok := r.users[id]; ok {
out = append(out, u)
}
}
return out, nil
}

58
internal/app/phone/dh.go Normal file
View file

@ -0,0 +1,58 @@
// Package phone 实现私聊 1:1 通话的信令状态机与 DH 参数下发。
//
// 服务端职责边界:信令转发、状态机、commit-reveal 核验(SHA256(g_a)==g_a_hash)、
// connections 下发。媒体面由客户端 tgcalls 走 P2P/TURN,密钥交换是 E2E 的,
// 服务端不知道也无法验证共享密钥本身。
package phone
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// DHConfigVersion 是 messages.getDhConfig 的静态版本号。p/g 是编译期常量,
// 客户端缓存命中(请求 version 相同)时只回 dhConfigNotModified{random}。
const DHConfigVersion = 1
// DHG 是 DH generator。与官方一致取 3:TDesktop MTP::IsPrimeAndGood 对
// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径,DrKLO native 同。
const DHG = 3
// dhPrimeHex 是官方 2048-bit safe prime。与 internal/app/account/srp.go 的
// baseP 同值(SRP 与通话 DH 共用官方参数);改动任一处须同步另一处。
const dhPrimeHex = "c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b"
var dhPrime = mustDecodeHex(dhPrimeHex)
// maxDHRandomLength 钳制客户端请求的随机字节数,防御恶意大请求。
const maxDHRandomLength = 1024
// DHPrime 返回官方 2048-bit prime 的拷贝。
func DHPrime() []byte {
return append([]byte(nil), dhPrime...)
}
// DHRandom 生成恰好 n 字节加密随机数;n 钳制到 [0, maxDHRandomLength]。
// 客户端契约要求 random 尺寸与请求一致(TDesktop 校验 random.size() 与其请求相同)。
func DHRandom(n int) ([]byte, error) {
if n < 0 {
n = 0
}
if n > maxDHRandomLength {
n = maxDHRandomLength
}
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("phone: dh random: %w", err)
}
return buf, nil
}
func mustDecodeHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(fmt.Sprintf("phone: invalid dh prime hex: %v", err))
}
return b
}

View file

@ -0,0 +1,120 @@
package phone
import (
"crypto/rand"
"encoding/binary"
"fmt"
"sync"
"telesrv/internal/domain"
)
// registry 是 active call 的进程内权威存储:单实现、不进 store 双实现体系。
//
// 论证(设计已确认,勿改回双 store):active call 是秒级短命状态,服务端重启后
// 客户端侧媒体连接早已断开,「重启恢复半截通话」不是有效需求;单一实现让测试与
// 生产共用同一份代码,从构造上消灭 memory/postgres 行为漂移。多实例化时以同样的
// 窄接口换 Redis 实现,信令层零改动。
type registry struct {
mu sync.Mutex
byID map[int64]*entry
byRandom map[randomKey]int64 // (callerID, randomID) → callID,吸收客户端 RPC 重试
active map[int64]int // userID → 非终态通话数(并发上限依据)
}
type randomKey struct {
callerID int64
randomID int64
}
// entry 持有一通通话;call 字段由 registry.mu 保护。
// sigMu 单独串行化该通话的信令转发(锁内做推送入队),与状态锁分离,
// 保证 discard 等状态迁移不被对端出站队列堵塞拖住。
type entry struct {
call domain.PhoneCall
sigMu sync.Mutex
sigWindowSec int64
sigCount int
}
func newRegistry() *registry {
return &registry{
byID: make(map[int64]*entry),
byRandom: make(map[randomKey]int64),
active: make(map[int64]int),
}
}
// sweepLocked 是 P1 的纯年龄 GC(调用方持有 r.mu):
// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁);
// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话
// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。
func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) {
for id, e := range r.byID {
switch {
case e.call.Terminal():
if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
r.removeLocked(id, e, false)
}
default:
if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec {
r.removeLocked(id, e, true)
}
}
}
}
func (r *registry) removeLocked(id int64, e *entry, wasActive bool) {
delete(r.byID, id)
delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID})
if wasActive {
r.decActiveLocked(e.call.AdminID)
}
}
func (r *registry) decActiveLocked(userID int64) {
if n := r.active[userID]; n <= 1 {
delete(r.active, userID)
} else {
r.active[userID] = n - 1
}
}
// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。
func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) {
if e.call.Terminal() {
return
}
e.call.State = domain.PhoneCallStateDiscarded
e.call.DiscardReason = reason
e.call.Duration = duration
e.call.DiscardedAt = nowUnix
r.decActiveLocked(e.call.AdminID)
}
// newID 生成 registry 内唯一的正 int64(调用方持有 r.mu)。
func (r *registry) newIDLocked() (int64, error) {
for i := 0; i < 32; i++ {
id, err := randomPositiveInt64()
if err != nil {
return 0, err
}
if _, exists := r.byID[id]; !exists {
return id, nil
}
}
return 0, fmt.Errorf("phone: exhausted call id attempts")
}
func randomPositiveInt64() (int64, error) {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, fmt.Errorf("phone: random id: %w", err)
}
v := int64(binary.BigEndian.Uint64(buf[:]) >> 1)
if v == 0 {
v = 1
}
return v, nil
}

View file

@ -0,0 +1,502 @@
package phone
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"strconv"
"strings"
"time"
"github.com/gotd/td/clock"
"telesrv/internal/domain"
)
// 客户端可识别的业务错误;rpc 层映射为对应 RPC_ERROR(CALL_* 等)。
var (
ErrPeerInvalid = errors.New("phone: call peer invalid")
ErrAlreadyAccepted = errors.New("phone: call already accepted")
ErrAlreadyDeclined = errors.New("phone: call already declined")
ErrOccupyFailed = errors.New("phone: too many active calls")
ErrProtocolLayerInvalid = errors.New("phone: protocol layer invalid")
ErrProtocolCompatLayerInvalid = errors.New("phone: protocol compat layer invalid")
ErrProtocolFlagsInvalid = errors.New("phone: protocol flags invalid")
// ErrGAHashMismatch:confirmCall 揭示的 g_a 与 requestCall 承诺的 SHA256 不符。
// 服务端同时把通话强制置为 discarded(disconnect),防止攻击者卡死状态机。
ErrGAHashMismatch = errors.New("phone: g_a does not match committed hash")
)
// minSupportedLayer 是 libtgvoip/tgcalls 的最低协议层(TDesktop kMinLayer、
// DrKLO VoIPService.CALL_MIN_LAYER 均为 65)。
const minSupportedLayer = 65
const (
gaHashSize = sha256.Size // 32
dhPubSize = 256 // g_a / g_b 都是 2048-bit
)
// Config 是通话服务的运行参数。
type Config struct {
// RingTimeout 是服务端兜底超时(与下发给客户端的 callRingTimeoutMs 同源,默认 90s)。
RingTimeout time.Duration
// TombstoneTTL 是终态 tombstone 保留期(吸收双方同时挂断/晚到 RPC 的幂等窗口)。
TombstoneTTL time.Duration
// MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。
MaxActivePerUser int
// SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。
SignalingRatePerSecond int
}
func (c Config) withDefaults() Config {
if c.RingTimeout <= 0 {
c.RingTimeout = 90 * time.Second
}
if c.TombstoneTTL <= 0 {
c.TombstoneTTL = 60 * time.Second
}
if c.MaxActivePerUser <= 0 {
c.MaxActivePerUser = 4
}
if c.SignalingRatePerSecond <= 0 {
c.SignalingRatePerSecond = 50
}
return c
}
// Service 实现私聊通话信令状态机。所有方法返回的 domain.PhoneCall 都是当时快照。
type Service struct {
cfg Config
clk clock.Clock
reg *registry
}
// Option 配置 Service。
type Option func(*Service)
// WithClock 注入测试时钟。
func WithClock(clk clock.Clock) Option {
return func(s *Service) { s.clk = clk }
}
// NewService 创建通话服务。
func NewService(cfg Config, opts ...Option) *Service {
s := &Service{cfg: cfg.withDefaults(), clk: clock.System, reg: newRegistry()}
for _, opt := range opts {
opt(s)
}
return s
}
// RequestCall 受理主叫请求:校验协议与配额、(callerID, randomID) 幂等去重、建档。
// 隐私/拉黑/目标用户合法性由 rpc 层先行校验。
func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.PhoneCallRequest) (domain.PhoneCall, error) {
if err := validateProtocol(in.Protocol); err != nil {
return domain.PhoneCall{}, err
}
if len(in.GAHash) != gaHashSize {
return domain.PhoneCall{}, ErrProtocolFlagsInvalid
}
now := s.clk.Now()
nowUnix := now.Unix()
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second))
// 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。
key := randomKey{callerID: callerID, randomID: in.RandomID}
if id, ok := s.reg.byRandom[key]; ok {
if e, ok := s.reg.byID[id]; ok && !e.call.Terminal() {
return e.call, nil
}
}
if s.reg.active[callerID] >= s.cfg.MaxActivePerUser {
return domain.PhoneCall{}, ErrOccupyFailed
}
id, err := s.reg.newIDLocked()
if err != nil {
return domain.PhoneCall{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.PhoneCall{}, err
}
call := domain.PhoneCall{
ID: id,
AccessHash: accessHash,
AdminID: callerID,
ParticipantID: in.CalleeID,
Video: in.Video,
State: domain.PhoneCallStateRequested,
Date: int(nowUnix),
GAHash: append([]byte(nil), in.GAHash...),
CallerProtocol: in.Protocol,
Protocol: in.Protocol,
RandomID: in.RandomID,
CallerDevice: in.CallerDevice,
PrivacyP2P: in.PrivacyP2P,
Connections: append([]domain.PhoneCallConnection(nil), in.Connections...),
}
s.reg.byID[id] = &entry{call: call}
s.reg.byRandom[key] = id
s.reg.active[callerID]++
return call, nil
}
// ReceivedCall 标记被叫设备已收到来电。首次(Requested→Ringing)返回 transitioned=true,
// 其余状态幂等成功(多设备各自上报、晚到无害)。
func (s *Service) ReceivedCall(ctx context.Context, userID, callID, accessHash int64) (domain.PhoneCall, bool, error) {
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
e, err := s.lookupLocked(callID, accessHash)
if err != nil {
return domain.PhoneCall{}, false, err
}
if userID != e.call.ParticipantID {
// receivedCall 只能由被叫上报。
return domain.PhoneCall{}, false, ErrPeerInvalid
}
if e.call.State == domain.PhoneCallStateRequested {
e.call.State = domain.PhoneCallStateRinging
e.call.ReceiveDate = int(s.clk.Now().Unix())
return e.call, true, nil
}
return e.call, false, nil
}
// AcceptCall 受理被叫接听。多设备并发竞争由 registry 锁串行化:首个完成迁移者赢,
// 后到者收 ErrAlreadyAccepted(其 UI 自行收场)。
func (s *Service) AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error) {
if err := validateProtocol(proto); err != nil {
return domain.PhoneCall{}, err
}
if len(gb) != dhPubSize || allZero(gb) {
return domain.PhoneCall{}, ErrProtocolFlagsInvalid
}
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
e, err := s.lookupLocked(callID, accessHash)
if err != nil {
return domain.PhoneCall{}, err
}
if userID != e.call.ParticipantID {
return domain.PhoneCall{}, ErrPeerInvalid
}
switch e.call.State {
case domain.PhoneCallStateRequested, domain.PhoneCallStateRinging:
// 合法路径;未 receivedCall 直接 accept 也允许。
case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed:
return domain.PhoneCall{}, ErrAlreadyAccepted
case domain.PhoneCallStateDiscarded:
return domain.PhoneCall{}, ErrAlreadyDeclined
}
negotiated, err := negotiateProtocol(e.call.CallerProtocol, proto)
if err != nil {
return domain.PhoneCall{}, err
}
e.call.GB = append([]byte(nil), gb...)
e.call.CalleeProtocol = proto
e.call.Protocol = negotiated
e.call.CalleeDevice = device
e.call.State = domain.PhoneCallStateAccepted
// 不解除超时:同一只表继续走,主叫永不 confirm 时由兜底超时收尾。
return e.call, nil
}
// ConfirmCall 受理主叫确认:核验 SHA256(g_a) 与承诺一致后进入 Confirmed。
// 核验失败时通话被强制置为 discarded(disconnect),返回 (终态快照, true, ErrGAHashMismatch),
// 调用方须把终态推送给双方。
func (s *Service) ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (domain.PhoneCall, bool, error) {
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
e, err := s.lookupLocked(callID, accessHash)
if err != nil {
return domain.PhoneCall{}, false, err
}
if userID != e.call.AdminID {
return domain.PhoneCall{}, false, ErrPeerInvalid
}
switch e.call.State {
case domain.PhoneCallStateAccepted:
case domain.PhoneCallStateConfirmed:
return domain.PhoneCall{}, false, ErrAlreadyAccepted
case domain.PhoneCallStateDiscarded:
return domain.PhoneCall{}, false, ErrAlreadyDeclined
default: // Requested / Ringing:被叫尚未 accept,confirm 非法。
return domain.PhoneCall{}, false, ErrPeerInvalid
}
// confirmCall 携带的第三份 protocol 仅校验合法性,不改 accept 时的协商结果
//(官方语义:协商在 accept 完成,confirm 的 protocol 是回显)。
if err := validateProtocol(proto); err != nil {
return domain.PhoneCall{}, false, err
}
if len(ga) != dhPubSize || sha256Mismatch(ga, e.call.GAHash) {
now := int(s.clk.Now().Unix())
s.reg.markDiscardedLocked(e, domain.PhoneCallDiscardReasonDisconnect, 0, now)
return e.call, true, ErrGAHashMismatch
}
e.call.GA = append([]byte(nil), ga...)
e.call.KeyFingerprint = keyFingerprint
e.call.StartDate = int(s.clk.Now().Unix())
// p2p_allowed = 双方 protocol 都允许 P2P ∧ phone_p2p 隐私双向放行(P3 起
// PrivacyP2P 由 rpc 层算定,强制 relay 时也走它置 false)。false 时 tgcalls
// 只用 relay candidates——前提是 connections 里有可用 TURN。
e.call.P2PAllowed = e.call.CallerProtocol.UDPP2P && e.call.CalleeProtocol.UDPP2P && e.call.PrivacyP2P
e.call.State = domain.PhoneCallStateConfirmed
return e.call, false, nil
}
// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态
// (双方同时挂断:先到者定 reason,后到者拿快照)。
func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) {
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
e, err := s.lookupLocked(callID, accessHash)
if err != nil {
return domain.PhoneCall{}, false, err
}
if !e.call.HasParticipant(userID) {
return domain.PhoneCall{}, false, ErrPeerInvalid
}
if e.call.Terminal() {
return e.call, true, nil
}
if reason == "" {
reason = domain.PhoneCallDiscardReasonHangup
}
// duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。
if e.call.StartDate == 0 || duration < 0 {
duration = 0
}
s.reg.markDiscardedLocked(e, reason, duration, int(s.clk.Now().Unix()))
return e.call, false, nil
}
// ExpireDue 把超时的非终态通话迁入终态并返回快照(调用方负责推送与落历史):
// Requested/Ringing 超时 → missed(即「未接来电」来源);Accepted 悬挂(主叫
// 永不 confirm)→ disconnect。Confirmed 通话没有服务端时长上限,不在此回收。
// 顺带做 tombstone GC。
func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCall {
nowUnix := now.Unix()
ringSec := int64(s.cfg.RingTimeout / time.Second)
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
var expired []domain.PhoneCall
for _, e := range s.reg.byID {
if e.call.Terminal() || e.call.State == domain.PhoneCallStateConfirmed {
continue
}
if nowUnix-int64(e.call.Date) <= ringSec {
continue
}
reason := domain.PhoneCallDiscardReasonMissed
if e.call.State == domain.PhoneCallStateAccepted {
reason = domain.PhoneCallDiscardReasonDisconnect
}
s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix))
expired = append(expired, e.call)
}
s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second))
return expired
}
// Signal 校验并串行转发一条信令。forward 在该通话专属的信令顺序锁内执行
// (保证转发顺序与受理顺序一致),状态锁不跨 forward 持有。peerDevice 是对端
// 受理设备锚点(可能为零值/已失效),仅作定向推送 fast-path 提示。
// drop=true 表示按契约静默吞掉(tombstone 尾包 / 超过速率上限),调用方应返回成功。
func (s *Service) Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error) {
s.reg.mu.Lock()
e, lookupErr := s.lookupLocked(callID, accessHash)
if lookupErr != nil {
s.reg.mu.Unlock()
return false, lookupErr
}
if !e.call.HasParticipant(userID) {
s.reg.mu.Unlock()
return false, ErrPeerInvalid
}
state := e.call.State
peer := e.call.PeerOf(userID)
peerDevice := e.call.CalleeDevice
if peer == e.call.AdminID {
peerDevice = e.call.CallerDevice
}
s.reg.mu.Unlock()
switch state {
case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed:
// 可转发。DrKLO 在 confirm 前后都可能发信令,Accepted 即放行。
case domain.PhoneCallStateDiscarded:
// 挂断瞬间的尾包:返回错误会让 TDesktop 把正常挂断渲染成「通话失败」
//(其 sendSignalingData 的 .done 校验 mtpIsTrue),静默丢弃。
return true, nil
default:
return false, ErrPeerInvalid
}
e.sigMu.Lock()
defer e.sigMu.Unlock()
nowSec := s.clk.Now().Unix()
if e.sigWindowSec != nowSec {
e.sigWindowSec = nowSec
e.sigCount = 0
}
if e.sigCount >= s.cfg.SignalingRatePerSecond {
return true, nil
}
e.sigCount++
forward(peer, peerDevice)
return false, nil
}
// Lookup 返回通话快照(rpc 层宽容校验 setCallRating/saveCallDebug 等晚到请求用)。
func (s *Service) Lookup(ctx context.Context, callID, accessHash int64) (domain.PhoneCall, bool) {
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
e, err := s.lookupLocked(callID, accessHash)
if err != nil {
return domain.PhoneCall{}, false
}
return e.call, true
}
func (s *Service) lookupLocked(callID, accessHash int64) (*entry, error) {
e, ok := s.reg.byID[callID]
if !ok || e.call.AccessHash != accessHash {
return nil, ErrPeerInvalid
}
return e, nil
}
func validateProtocol(p domain.PhoneCallProtocol) error {
if p.MinLayer > p.MaxLayer {
return ErrProtocolLayerInvalid
}
if p.MaxLayer < minSupportedLayer {
return ErrProtocolCompatLayerInvalid
}
if !p.UDPP2P && !p.UDPReflector {
return ErrProtocolFlagsInvalid
}
if len(p.LibraryVersions) == 0 {
return ErrProtocolFlagsInvalid
}
return nil
}
// negotiateProtocol 在 accept 时合并双方 protocol(官方语义:服务端只回「最优」单值
// library version)。版本无交集时透传被叫列表(被叫是先实例化 tgcalls 的一方,
// 主叫侧有 kDefaultVersion 兜底)——绝不因版本差异拒绝通话。
func negotiateProtocol(caller, callee domain.PhoneCallProtocol) (domain.PhoneCallProtocol, error) {
out := domain.PhoneCallProtocol{
UDPP2P: caller.UDPP2P && callee.UDPP2P,
UDPReflector: caller.UDPReflector || callee.UDPReflector,
MinLayer: maxInt(caller.MinLayer, callee.MinLayer),
MaxLayer: minInt(caller.MaxLayer, callee.MaxLayer),
}
if out.MinLayer > out.MaxLayer {
return domain.PhoneCallProtocol{}, ErrProtocolCompatLayerInvalid
}
if best, ok := bestCommonVersion(caller.LibraryVersions, callee.LibraryVersions); ok {
out.LibraryVersions = []string{best}
} else {
out.LibraryVersions = append([]string(nil), callee.LibraryVersions...)
}
return out, nil
}
// preferredVersions 是交集命中时的优先选择序(读客户端源码定下的硬约束):
// - "9.0.0"=InstanceV2Impl+V2 消息级信令,TDesktop 与 DrKLO 注册表都有,最稳;
// - ⚠ 绝不能选 "10.0.0"/"11.0.0"/"12.0.0"/"13.0.0" 当 [0]:DrKLO 的视频可用性
// 判断是 `"2.7.7".compareTo(versions[0]) <= 0` 的**字符串字典序**比较
// (VoIPService.java:3464),"1x.0.0" 字典序小于 "2.7.7" 会让 Android 直接
// 销毁摄像头采集(视频通话黑屏);"12/13" 还是 V3 SCTP-over-signaling,
// 对服务端信令限速不友好;
// - 也绝不能选 "2.7.7"/"5.0.0"/"2.4.4":legacy 实现依赖我们不下发的
// reflector endpoints,无路可走。
var preferredVersions = []string{"9.0.0", "8.0.0", "7.0.0"}
// bestCommonVersion 取两侧版本集合交集:优先 preferredVersions 顺位命中,
// 否则退化为语义化最高者(容忍未来未知版本集)。
func bestCommonVersion(a, b []string) (string, bool) {
inB := make(map[string]struct{}, len(b))
for _, v := range b {
inB[v] = struct{}{}
}
inBoth := make(map[string]struct{}, len(a))
for _, v := range a {
if _, ok := inB[v]; ok {
inBoth[v] = struct{}{}
}
}
for _, v := range preferredVersions {
if _, ok := inBoth[v]; ok {
return v, true
}
}
best, found := "", false
for v := range inBoth {
if !found || compareVersion(v, best) > 0 {
best, found = v, true
}
}
return best, found
}
// compareVersion 按点分十进制比较("9.0.0" < "10.0.0");非数字段退化为字符串比较。
func compareVersion(a, b string) int {
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var av, bv string
if i < len(as) {
av = as[i]
}
if i < len(bs) {
bv = bs[i]
}
an, aerr := strconv.Atoi(av)
bn, berr := strconv.Atoi(bv)
switch {
case aerr == nil && berr == nil:
if an != bn {
return an - bn
}
default:
if c := strings.Compare(av, bv); c != 0 {
return c
}
}
}
return 0
}
func sha256Mismatch(data, want []byte) bool {
got := sha256.Sum256(data)
return !bytes.Equal(got[:], want)
}
func allZero(b []byte) bool {
for _, v := range b {
if v != 0 {
return false
}
}
return true
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,479 @@
package phone
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"time"
"github.com/gotd/td/clock"
"telesrv/internal/domain"
)
type testClock struct {
mu sync.Mutex
now time.Time
}
func newTestClock() *testClock {
return &testClock{now: time.Unix(1_700_000_000, 0)}
}
func (c *testClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *testClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
func (c *testClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
func (c *testClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
func testProtocol(versions ...string) domain.PhoneCallProtocol {
if len(versions) == 0 {
versions = []string{"11.0.0", "10.0.0"}
}
return domain.PhoneCallProtocol{
UDPP2P: true,
UDPReflector: true,
MinLayer: 65,
MaxLayer: 92,
LibraryVersions: versions,
}
}
func testGA() ([]byte, []byte) {
ga := make([]byte, 256)
for i := range ga {
ga[i] = byte(i + 1)
}
hash := sha256.Sum256(ga)
return ga, hash[:]
}
func testGB() []byte {
gb := make([]byte, 256)
for i := range gb {
gb[i] = byte(255 - i%200)
}
return gb
}
func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
cfg := Config{
RingTimeout: 90 * time.Second,
TombstoneTTL: 60 * time.Second,
MaxActivePerUser: 4,
SignalingRatePerSecond: 50,
}
for _, fn := range mutate {
fn(&cfg)
}
return NewService(cfg, WithClock(clk))
}
func mustRequest(t *testing.T, s *Service, caller, callee int64, gaHash []byte) domain.PhoneCall {
t.Helper()
call, err := s.RequestCall(context.Background(), caller, domain.PhoneCallRequest{
CalleeID: callee,
RandomID: caller*1000 + callee,
GAHash: gaHash,
Protocol: testProtocol(),
PrivacyP2P: true, // rpc 层算定的 phone_p2p 双向放行(P3 起参与 p2p_allowed AND)
})
if err != nil {
t.Fatalf("RequestCall: %v", err)
}
return call
}
func TestPhoneCallHappyPath(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
ga, gaHash := testGA()
gb := testGB()
call := mustRequest(t, s, 1, 2, gaHash)
if call.State != domain.PhoneCallStateRequested || call.AdminID != 1 || call.ParticipantID != 2 {
t.Fatalf("requested call = %+v", call)
}
clk.Advance(2 * time.Second)
ringing, transitioned, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash)
if err != nil || !transitioned || ringing.State != domain.PhoneCallStateRinging || ringing.ReceiveDate == 0 {
t.Fatalf("ReceivedCall = %+v transitioned=%v err=%v", ringing, transitioned, err)
}
if _, again, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash); err != nil || again {
t.Fatalf("second ReceivedCall transitioned=%v err=%v, want idempotent", again, err)
}
accepted, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{SessionID: 22})
if err != nil || accepted.State != domain.PhoneCallStateAccepted {
t.Fatalf("AcceptCall = %+v err=%v", accepted, err)
}
if string(accepted.GB) != string(gb) {
t.Fatalf("accepted.GB mismatch")
}
confirmed, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 0x1234, testProtocol())
if err != nil || forced || confirmed.State != domain.PhoneCallStateConfirmed {
t.Fatalf("ConfirmCall = %+v forced=%v err=%v", confirmed, forced, err)
}
if !confirmed.P2PAllowed || confirmed.KeyFingerprint != 0x1234 || confirmed.StartDate == 0 {
t.Fatalf("confirmed snapshot = %+v", confirmed)
}
clk.Advance(30 * time.Second)
discarded, already, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 30)
if err != nil || already || discarded.State != domain.PhoneCallStateDiscarded || discarded.Duration != 30 {
t.Fatalf("DiscardCall = %+v already=%v err=%v", discarded, already, err)
}
// 双方同时挂断:后到者幂等拿快照,reason 由先到者决定。
again, already, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 0)
if err != nil || !already || again.DiscardReason != domain.PhoneCallDiscardReasonHangup {
t.Fatalf("second DiscardCall = %+v already=%v err=%v", again, already, err)
}
}
func TestPhoneCallStateErrors(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
ga, gaHash := testGA()
gb := testGB()
call := mustRequest(t, s, 1, 2, gaHash)
// confirm 前置必须 Accepted。
if _, _, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 1, testProtocol()); !errors.Is(err, ErrPeerInvalid) {
t.Fatalf("confirm before accept err = %v, want ErrPeerInvalid", err)
}
// 非被叫不能 accept / receivedCall。
if _, err := s.AcceptCall(ctx, 1, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
t.Fatalf("accept by caller err = %v, want ErrPeerInvalid", err)
}
if _, _, err := s.ReceivedCall(ctx, 1, call.ID, call.AccessHash); !errors.Is(err, ErrPeerInvalid) {
t.Fatalf("receivedCall by caller err = %v, want ErrPeerInvalid", err)
}
// access_hash 不符。
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash+1, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
t.Fatalf("wrong access hash err = %v, want ErrPeerInvalid", err)
}
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyAccepted) {
t.Fatalf("double accept err = %v, want ErrAlreadyAccepted", err)
}
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
t.Fatalf("discard: %v", err)
}
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyDeclined) {
t.Fatalf("accept after discard err = %v, want ErrAlreadyDeclined", err)
}
}
func TestPhoneCallGAHashMismatchForcesDiscard(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
_, gaHash := testGA()
call := mustRequest(t, s, 1, 2, gaHash)
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
wrong := make([]byte, 256)
wrong[0] = 0x7
snap, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, wrong, 1, testProtocol())
if !errors.Is(err, ErrGAHashMismatch) || !forced {
t.Fatalf("confirm with wrong ga: forced=%v err=%v", forced, err)
}
if snap.State != domain.PhoneCallStateDiscarded || snap.DiscardReason != domain.PhoneCallDiscardReasonDisconnect {
t.Fatalf("forced discard snapshot = %+v", snap)
}
}
func TestPhoneCallConcurrentAcceptSingleWinner(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
_, gaHash := testGA()
call := mustRequest(t, s, 1, 2, gaHash)
const devices = 8
var wg sync.WaitGroup
wins := make(chan int64, devices)
losses := make(chan error, devices)
for i := 0; i < devices; i++ {
wg.Add(1)
go func(sessionID int64) {
defer wg.Done()
_, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{SessionID: sessionID})
if err == nil {
wins <- sessionID
} else {
losses <- err
}
}(int64(100 + i))
}
wg.Wait()
close(wins)
close(losses)
if len(wins) != 1 {
t.Fatalf("winners = %d, want exactly 1", len(wins))
}
for err := range losses {
if !errors.Is(err, ErrAlreadyAccepted) {
t.Fatalf("loser err = %v, want ErrAlreadyAccepted", err)
}
}
winner := <-wins
snap, ok := s.Lookup(ctx, call.ID, call.AccessHash)
if !ok || snap.CalleeDevice.SessionID != winner {
t.Fatalf("callee device = %+v, want session %d", snap.CalleeDevice, winner)
}
}
func TestPhoneCallRandomIDIdempotent(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
_, gaHash := testGA()
req := domain.PhoneCallRequest{CalleeID: 2, RandomID: 777, GAHash: gaHash, Protocol: testProtocol()}
first, err := s.RequestCall(ctx, 1, req)
if err != nil {
t.Fatalf("request: %v", err)
}
second, err := s.RequestCall(ctx, 1, req)
if err != nil || second.ID != first.ID {
t.Fatalf("retry id = %d err=%v, want %d", second.ID, err, first.ID)
}
// 终结后同 random_id 重新可用(新通话)。
if _, _, err := s.DiscardCall(ctx, 1, first.ID, first.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
t.Fatalf("discard: %v", err)
}
third, err := s.RequestCall(ctx, 1, req)
if err != nil || third.ID == first.ID {
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
}
}
func TestPhoneCallQuotaAndSweep(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
ctx := context.Background()
_, gaHash := testGA()
for i := int64(0); i < 2; i++ {
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 10 + i, RandomID: i, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
t.Fatalf("request %d: %v", i, err)
}
}
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
}
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
clk.Advance(181 * time.Second)
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
t.Fatalf("request after sweep: %v", err)
}
}
func TestPhoneCallTombstoneGC(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
_, gaHash := testGA()
call := mustRequest(t, s, 1, 2, gaHash)
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
t.Fatalf("discard: %v", err)
}
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); !ok {
t.Fatalf("tombstone should be visible before TTL")
}
clk.Advance(61 * time.Second)
mustRequest(t, s, 3, 4, gaHash) // 触发 sweep
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); ok {
t.Fatalf("tombstone should be collected after TTL")
}
}
func TestPhoneCallDurationOnlyWhenConfirmed(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
_, gaHash := testGA()
call := mustRequest(t, s, 1, 2, gaHash)
snap, _, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 55)
if err != nil || snap.Duration != 0 {
t.Fatalf("unconfirmed discard duration = %d err=%v, want 0", snap.Duration, err)
}
}
func TestPhoneCallSignal(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.SignalingRatePerSecond = 2 })
ctx := context.Background()
_, gaHash := testGA()
call := mustRequest(t, s, 1, 2, gaHash)
// Accepted 前不可转发。
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, func(int64, domain.SessionRef) {}); !errors.Is(err, ErrPeerInvalid) {
t.Fatalf("signal before accept err = %v, want ErrPeerInvalid", err)
}
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
var forwarded []int64
forward := func(peer int64, _ domain.SessionRef) { forwarded = append(forwarded, peer) }
for i := 0; i < 3; i++ {
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil {
t.Fatalf("signal %d: %v", i, err)
}
}
// 限速 2/s:第三条被静默丢弃。
if len(forwarded) != 2 || forwarded[0] != 2 || forwarded[1] != 2 {
t.Fatalf("forwarded = %v, want [2 2]", forwarded)
}
clk.Advance(time.Second)
if drop, err := s.Signal(ctx, 2, call.ID, call.AccessHash, forward); err != nil || drop {
t.Fatalf("signal new window drop=%v err=%v", drop, err)
}
if forwarded[len(forwarded)-1] != 1 {
t.Fatalf("callee→caller forward peer = %d, want 1", forwarded[len(forwarded)-1])
}
// 终态尾包静默吞掉。
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
t.Fatalf("discard: %v", err)
}
if drop, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil || !drop {
t.Fatalf("signal after discard drop=%v err=%v, want drop", drop, err)
}
}
func TestNegotiateProtocol(t *testing.T) {
base := func(min, max int, versions ...string) domain.PhoneCallProtocol {
return domain.PhoneCallProtocol{UDPP2P: true, UDPReflector: true, MinLayer: min, MaxLayer: max, LibraryVersions: versions}
}
t.Run("layer intersection", func(t *testing.T) {
out, err := negotiateProtocol(base(65, 92, "9.0.0"), base(70, 110, "9.0.0"))
if err != nil || out.MinLayer != 70 || out.MaxLayer != 92 {
t.Fatalf("negotiated = %+v err=%v", out, err)
}
})
t.Run("layer disjoint", func(t *testing.T) {
if _, err := negotiateProtocol(base(65, 70, "9.0.0"), base(80, 92, "9.0.0")); !errors.Is(err, ErrProtocolCompatLayerInvalid) {
t.Fatalf("err = %v, want ErrProtocolCompatLayerInvalid", err)
}
})
t.Run("best common version is semver max", func(t *testing.T) {
out, err := negotiateProtocol(base(65, 92, "11.0.0", "9.0.0", "2.4.4"), base(65, 92, "2.4.4", "9.0.0"))
if err != nil || len(out.LibraryVersions) != 1 || out.LibraryVersions[0] != "9.0.0" {
t.Fatalf("versions = %v err=%v, want [9.0.0]", out.LibraryVersions, err)
}
})
t.Run("preferred version beats semver max", func(t *testing.T) {
// ⚠ "9.0.0" 优先于更高版本:DrKLO 视频 gate 是字符串字典序比较
//("1x.0.0" < "2.7.7" 会判不支持视频),且 12/13 走 V3 SCTP 信令。
out, err := negotiateProtocol(base(65, 92, "13.0.0", "10.0.0", "9.0.0"), base(65, 92, "9.0.0", "10.0.0", "13.0.0"))
if err != nil || out.LibraryVersions[0] != "9.0.0" {
t.Fatalf("versions = %v err=%v, want preferred [9.0.0]", out.LibraryVersions, err)
}
})
t.Run("numeric compare fallback not lexicographic", func(t *testing.T) {
// 交集无 preferred 版本时退化为语义化最高(数值比较,非字典序)。
out, err := negotiateProtocol(base(65, 92, "10.0.0", "11.0.0"), base(65, 92, "11.0.0", "10.0.0"))
if err != nil || out.LibraryVersions[0] != "11.0.0" {
t.Fatalf("versions = %v err=%v, want [11.0.0]", out.LibraryVersions, err)
}
})
t.Run("no common versions passes callee list through", func(t *testing.T) {
// ⚠ P1-3:版本无交集绝不拒绝通话,透传被叫列表。
out, err := negotiateProtocol(base(65, 92, "11.0.0"), base(65, 92, "2.4.4", "3.0.0"))
if err != nil || len(out.LibraryVersions) != 2 || out.LibraryVersions[0] != "2.4.4" {
t.Fatalf("versions = %v err=%v, want callee passthrough", out.LibraryVersions, err)
}
})
}
func TestValidateProtocol(t *testing.T) {
cases := []struct {
name string
p domain.PhoneCallProtocol
want error
}{
{"min over max", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 93, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolLayerInvalid},
{"max below 65", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 60, MaxLayer: 64, LibraryVersions: []string{"9.0.0"}}, ErrProtocolCompatLayerInvalid},
{"no transport flags", domain.PhoneCallProtocol{MinLayer: 65, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolFlagsInvalid},
{"no versions", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 65, MaxLayer: 92}, ErrProtocolFlagsInvalid},
{"ok", testProtocol(), nil},
}
for _, tc := range cases {
if err := validateProtocol(tc.p); !errors.Is(err, tc.want) {
t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want)
}
}
}
func TestPhoneCallExpireDue(t *testing.T) {
clk := newTestClock()
s := newTestService(clk)
ctx := context.Background()
ga, gaHash := testGA()
// 三通通话:振铃中(→missed)、Accepted 悬挂(→disconnect)、Confirmed(不回收)。
ringingCall := mustRequest(t, s, 1, 2, gaHash)
acceptedCall, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{CalleeID: 4, RandomID: 1, GAHash: gaHash, Protocol: testProtocol()})
if err != nil {
t.Fatalf("request accepted call: %v", err)
}
if _, err := s.AcceptCall(ctx, 4, acceptedCall.ID, acceptedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
confirmedCall, err := s.RequestCall(ctx, 5, domain.PhoneCallRequest{CalleeID: 6, RandomID: 2, GAHash: gaHash, Protocol: testProtocol()})
if err != nil {
t.Fatalf("request confirmed call: %v", err)
}
if _, err := s.AcceptCall(ctx, 6, confirmedCall.ID, confirmedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept confirmed: %v", err)
}
if _, _, err := s.ConfirmCall(ctx, 5, confirmedCall.ID, confirmedCall.AccessHash, ga, 1, testProtocol()); err != nil {
t.Fatalf("confirm: %v", err)
}
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("nothing should expire yet, got %d", len(got))
}
clk.Advance(91 * time.Second)
expired := s.ExpireDue(ctx, clk.Now())
if len(expired) != 2 {
t.Fatalf("expired = %d, want 2 (ringing+accepted)", len(expired))
}
reasons := map[int64]domain.PhoneCallDiscardReason{}
for _, c := range expired {
reasons[c.ID] = c.DiscardReason
}
if reasons[ringingCall.ID] != domain.PhoneCallDiscardReasonMissed {
t.Fatalf("ringing call reason = %s, want missed", reasons[ringingCall.ID])
}
if reasons[acceptedCall.ID] != domain.PhoneCallDiscardReasonDisconnect {
t.Fatalf("accepted call reason = %s, want disconnect", reasons[acceptedCall.ID])
}
// Confirmed 通话不受服务端时长限制。
if snap, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
t.Fatalf("confirmed call = %+v ok=%v, want untouched", snap, ok)
}
// 幂等:再跑一轮无新增。
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("second ExpireDue = %d, want 0", len(got))
}
}

View file

@ -0,0 +1,49 @@
package polls
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 提供 poll 权威态的发送时创建与投票人列表查询。
// 投票/关闭走 messages/channels 服务(需要消息可见性),不经本服务。
type Service struct {
polls store.PollStore
}
// NewService 创建 poll 服务。
func NewService(polls store.PollStore) *Service {
return &Service{polls: polls}
}
// CreatePoll 在消息发送前落 poll 权威行(消息发送失败产生的孤儿行无害)。
func (s *Service) CreatePoll(ctx context.Context, def domain.PollDefinition) error {
if s == nil || s.polls == nil {
return domain.ErrPollInvalid
}
if def.ID == 0 || def.CreatorUserID == 0 || len(def.Options) < domain.MinPollAnswers || len(def.Options) > domain.MaxPollAnswers {
return domain.ErrPollInvalid
}
return s.polls.CreatePoll(ctx, def)
}
// GetPollDefinition 返回权威定义(getPollVotes 的 public_voters/broadcast 校验用)。
func (s *Service) GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error) {
if s == nil || s.polls == nil || pollID == 0 {
return domain.PollDefinition{}, false, nil
}
return s.polls.GetPollDefinition(ctx, pollID)
}
// ListPollVotes 分页列出投票人(仅 public_voters poll 由 rpc 层放行)。
func (s *Service) ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) {
if s == nil || s.polls == nil {
return domain.PollVotesList{}, domain.ErrPollNotFound
}
if req.PollID == 0 || req.Limit <= 0 || req.Limit > domain.MaxPollVotesPageLimit {
return domain.PollVotesList{}, domain.ErrPollInvalid
}
return s.polls.ListPollVotes(ctx, req)
}

View file

@ -0,0 +1,209 @@
package privacy
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
DefaultPrivacyRulesCacheTTL = 24 * time.Hour
privacySnapshotMaxOwners = 8192
)
var allPrivacyRuleKeys = []domain.PrivacyKey{
domain.PrivacyKeyStatusTimestamp,
domain.PrivacyKeyChatInvite,
domain.PrivacyKeyPhoneCall,
domain.PrivacyKeyPhoneP2P,
domain.PrivacyKeyForwards,
domain.PrivacyKeyProfilePhoto,
domain.PrivacyKeyPhoneNumber,
domain.PrivacyKeyAddedByPhone,
domain.PrivacyKeyVoiceMessages,
domain.PrivacyKeyAbout,
domain.PrivacyKeyBirthday,
domain.PrivacyKeyStarGiftsAutoSave,
domain.PrivacyKeyNoPaidMessages,
domain.PrivacyKeySavedMusic,
}
// privacyRulesMap 是单个 owner 的全部隐私规则(空 map = 查过且无规则,即负缓存)。
type privacyRulesMap map[domain.PrivacyKey]domain.PrivacyRules
// CachedPrivacyStore 是 account privacy rules 的 owner 级 read-model 缓存,由统一缓存原语承载
// (LRU 单条驱逐 / epoch 守卫 / clone)。owner 级、变更稀少:一次性装入某 owner 全部 key,让
// projectBatch/CanSeeMatrix 在内存里判 phone/status/photo 可见性,免去反复规划 account_privacy_rules。
// 单 owner 走 GetOrLoad,多 owner 走 GetOrLoadBatch(一次 LoadEpoch + 合批 ListPrivacyRules + 写回)。
type CachedPrivacyStore struct {
inner store.PrivacyStore
cache *readmodelcache.Cache[int64, privacyRulesMap]
}
func NewCachedPrivacyStore(inner store.PrivacyStore, ttl time.Duration) *CachedPrivacyStore {
if inner == nil {
return nil
}
if ttl <= 0 {
ttl = DefaultPrivacyRulesCacheTTL
}
return &CachedPrivacyStore{
inner: inner,
cache: readmodelcache.New[int64, privacyRulesMap](readmodelcache.Config[int64, privacyRulesMap]{
MaxEntries: privacySnapshotMaxOwners,
TTL: ttl,
Clone: clonePrivacyRulesMap,
}),
}
}
func (c *CachedPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
if ownerUserID == 0 {
return domain.PrivacyRules{}, false, nil
}
rules, err := c.ownerRules(ctx, ownerUserID)
if err != nil {
return domain.PrivacyRules{}, false, err
}
r, ok := rules[key]
if !ok {
return domain.PrivacyRules{}, false, nil
}
return cloneRules(r), true, nil
}
func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
if err := c.inner.SetPrivacyRules(ctx, rules); err != nil {
return err
}
c.InvalidateOwners(rules.OwnerUserID)
return nil
}
func (c *CachedPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
owners := dedupPrivacyOwnerIDs(ownerUserIDs)
if len(owners) == 0 || len(keys) == 0 {
return nil, nil
}
byOwner, err := c.ownerRulesBatch(ctx, owners)
if err != nil {
return nil, err
}
keySet := make(map[domain.PrivacyKey]struct{}, len(keys))
for _, key := range keys {
keySet[key] = struct{}{}
}
out := make([]domain.PrivacyRules, 0, len(owners)*len(keys))
for _, owner := range owners {
for key, rules := range byOwner[owner] {
if _, want := keySet[key]; !want {
continue
}
out = append(out, cloneRules(rules))
}
}
return out, nil
}
func (c *CachedPrivacyStore) ownerRules(ctx context.Context, ownerUserID int64) (privacyRulesMap, error) {
load := func() (privacyRulesMap, error) {
list, err := c.inner.ListPrivacyRules(ctx, []int64{ownerUserID}, allPrivacyRuleKeys)
if err != nil {
return nil, err
}
return buildPrivacyRulesByOwner(list, []int64{ownerUserID})[ownerUserID], nil
}
if c == nil || c.cache == nil {
return load()
}
return c.cache.GetOrLoad(ctx, ownerUserID, load)
}
func (c *CachedPrivacyStore) ownerRulesBatch(ctx context.Context, owners []int64) (map[int64]privacyRulesMap, error) {
loadMissing := func(ctx context.Context, missing []int64) (map[int64]privacyRulesMap, error) {
list, err := c.inner.ListPrivacyRules(ctx, missing, allPrivacyRuleKeys)
if err != nil {
return nil, err
}
return buildPrivacyRulesByOwner(list, missing), nil
}
if c == nil || c.cache == nil {
return loadMissing(ctx, owners)
}
return c.cache.GetOrLoadBatch(ctx, owners,
func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门
loadMissing,
)
}
func (c *CachedPrivacyStore) InvalidateOwners(ids ...int64) {
if c == nil || c.cache == nil || len(ids) == 0 {
return
}
nonZero := make([]int64, 0, len(ids))
for _, id := range ids {
if id != 0 {
nonZero = append(nonZero, id)
}
}
c.cache.Invalidate(nonZero...)
}
func (c *CachedPrivacyStore) FlushReadModelCache() {
if c == nil || c.cache == nil {
return
}
c.cache.Flush()
}
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
out := make(map[int64]privacyRulesMap, len(owners))
for _, owner := range owners {
out[owner] = make(privacyRulesMap)
}
for _, item := range list {
if item.OwnerUserID == 0 || item.Key == "" {
continue
}
m, ok := out[item.OwnerUserID]
if !ok {
m = make(privacyRulesMap)
out[item.OwnerUserID] = m
}
m[item.Key] = cloneRules(item)
}
return out
}
func clonePrivacyRulesMap(in privacyRulesMap) privacyRulesMap {
if in == nil {
return nil
}
out := make(privacyRulesMap, len(in))
for key, rules := range in {
out[key] = cloneRules(rules)
}
return out
}
func dedupPrivacyOwnerIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -0,0 +1,355 @@
package privacy
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type blockingFirstPrivacyStore struct {
store.PrivacyStore
started chan struct{}
release chan struct{}
first []domain.PrivacyRules
mu sync.Mutex
firstUsed bool
}
func (s *blockingFirstPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
s.mu.Lock()
if !s.firstUsed {
s.firstUsed = true
s.mu.Unlock()
close(s.started)
select {
case <-s.release:
case <-ctx.Done():
return nil, ctx.Err()
}
out := make([]domain.PrivacyRules, len(s.first))
for i := range s.first {
out[i] = cloneRules(s.first[i])
}
return out, nil
}
s.mu.Unlock()
return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys)
}
func waitForPrivacyCacheTestSignal(t *testing.T, ch <-chan struct{}) {
t.Helper()
select {
case <-ch:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for cache test signal")
}
}
type countingPrivacyStore struct {
store.PrivacyStore
getCalls int
listCalls int
setCalls int
}
func (s *countingPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
s.getCalls++
return s.PrivacyStore.GetPrivacyRules(ctx, ownerUserID, key)
}
func (s *countingPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
s.setCalls++
return s.PrivacyStore.SetPrivacyRules(ctx, rules)
}
func (s *countingPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
s.listCalls++
return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys)
}
func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("seed privacy: %v", err)
}
counting := &countingPrivacyStore{PrivacyStore: base}
cached := NewCachedPrivacyStore(counting, 0)
first, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("first get ok=%v err=%v", ok, err)
}
if first.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("first rules = %+v, want disallow all", first.Rules)
}
second, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("second get ok=%v err=%v", ok, err)
}
if second.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("second rules = %+v, want disallow all", second.Rules)
}
if counting.getCalls != 0 {
t.Fatalf("GetPrivacyRules calls = %d, want 0", counting.getCalls)
}
if counting.listCalls != 1 {
t.Fatalf("ListPrivacyRules calls = %d, want 1 owner snapshot load", counting.listCalls)
}
}
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
counting := &countingPrivacyStore{PrivacyStore: base}
cached := NewCachedPrivacyStore(counting, 0)
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("set first: %v", err)
}
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
t.Fatalf("prime get ok=%v err=%v", ok, err)
}
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
}); err != nil {
t.Fatalf("set second: %v", err)
}
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("after invalidation get ok=%v err=%v", ok, err)
}
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
}
if counting.listCalls != 2 {
t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls)
}
}
func TestCachedPrivacyStoreExternalInvalidationAndFlush(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("seed privacy: %v", err)
}
counting := &countingPrivacyStore{PrivacyStore: base}
cached := NewCachedPrivacyStore(counting, 0)
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
t.Fatalf("prime get ok=%v err=%v", ok, err)
}
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
}); err != nil {
t.Fatalf("direct set: %v", err)
}
cached.InvalidateOwners(1001)
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("after external invalidation ok=%v err=%v", ok, err)
}
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("after invalidation = %+v, want allow all", got.Rules)
}
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("direct set 2: %v", err)
}
cached.FlushReadModelCache()
got, ok, err = cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("after flush ok=%v err=%v", ok, err)
}
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("after flush = %+v, want disallow all", got.Rules)
}
if counting.listCalls != 3 {
t.Fatalf("ListPrivacyRules calls = %d, want 3 after prime+invalidate+flush", counting.listCalls)
}
}
func TestCachedPrivacyStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("seed privacy: %v", err)
}
first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys)
if err != nil {
t.Fatalf("snapshot first privacy rules: %v", err)
}
blocking := &blockingFirstPrivacyStore{
PrivacyStore: base,
started: make(chan struct{}),
release: make(chan struct{}),
first: first,
}
cached := NewCachedPrivacyStore(blocking, 0)
type readResult struct {
rules domain.PrivacyRules
ok bool
err error
}
resultCh := make(chan readResult, 1)
go func() {
rules, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
resultCh <- readResult{rules: rules, ok: ok, err: err}
}()
waitForPrivacyCacheTestSignal(t, blocking.started)
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
}); err != nil {
t.Fatalf("update privacy while first load is blocked: %v", err)
}
cached.InvalidateOwners(1001)
close(blocking.release)
var result readResult
select {
case result = <-resultCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for privacy read")
}
if result.err != nil || !result.ok {
t.Fatalf("privacy read ok=%v err=%v", result.ok, result.err)
}
if result.rules.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("privacy after concurrent invalidation = %+v, want allow all", result.rules.Rules)
}
cachedHit, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("cached hit after stale load retry ok=%v err=%v", ok, err)
}
if cachedHit.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("cached privacy after stale load retry = %+v, want allow all", cachedHit.Rules)
}
}
func TestCachedPrivacyStoreDoesNotRefillStaleBatchAfterInvalidation(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("seed privacy: %v", err)
}
first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys)
if err != nil {
t.Fatalf("snapshot first privacy rules: %v", err)
}
blocking := &blockingFirstPrivacyStore{
PrivacyStore: base,
started: make(chan struct{}),
release: make(chan struct{}),
first: first,
}
cached := NewCachedPrivacyStore(blocking, 0)
type readResult struct {
rules []domain.PrivacyRules
err error
}
resultCh := make(chan readResult, 1)
go func() {
rules, err := cached.ListPrivacyRules(ctx, []int64{1001}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto})
resultCh <- readResult{rules: rules, err: err}
}()
waitForPrivacyCacheTestSignal(t, blocking.started)
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
}); err != nil {
t.Fatalf("update privacy while first batch load is blocked: %v", err)
}
cached.InvalidateOwners(1001)
close(blocking.release)
var result readResult
select {
case result = <-resultCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for privacy batch read")
}
if result.err != nil {
t.Fatalf("privacy batch read: %v", result.err)
}
if len(result.rules) != 1 || result.rules[0].Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("privacy batch after concurrent invalidation = %+v, want allow all", result.rules)
}
}
func TestCachedPrivacyStoreListUsesBatchOwnerSnapshots(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("seed 1001: %v", err)
}
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1002,
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}},
}); err != nil {
t.Fatalf("seed 1002: %v", err)
}
counting := &countingPrivacyStore{PrivacyStore: base}
cached := NewCachedPrivacyStore(counting, 0)
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyProfilePhoto}
first, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys)
if err != nil {
t.Fatalf("list first: %v", err)
}
second, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys)
if err != nil {
t.Fatalf("list second: %v", err)
}
if len(first) != 2 || len(second) != 2 {
t.Fatalf("list sizes = %d/%d, want 2/2", len(first), len(second))
}
if counting.listCalls != 1 {
t.Fatalf("ListPrivacyRules calls = %d, want 1 batch load", counting.listCalls)
}
}

View file

@ -110,6 +110,185 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
return Evaluate(rules, evalCtx), nil
}
// CanSeeBatch 批量评估多个 owner 对同一 viewer 在多个 key 上的可见性,结果等价于对每个
// (owner,key) 调一次 CanSee,但只用一次 ListPrivacyRules + 一次 GetReverseContacts + 内存
// Evaluate(消除 projectBatch / fan-out 投影里 per-user 3×CanSee×2行 的 N+1)。返回
// map[ownerUserID]map[key]bool;owner==viewer 恒 true(与 CanSee 一致)。
func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error) {
out := make(map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
if viewerUserID == 0 || len(ownerUserIDs) == 0 || len(keys) == 0 {
return out, nil
}
for _, k := range keys {
if !ValidKey(k) {
return nil, domain.ErrPrivacyKeyInvalid
}
}
owners := make([]int64, 0, len(ownerUserIDs))
seen := make(map[int64]struct{}, len(ownerUserIDs))
for _, id := range ownerUserIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if id == viewerUserID {
// 自己恒可见全部 key(与 CanSee 的 ownerUserID==viewerUserID 分支一致)。
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
m[k] = true
}
out[id] = m
continue
}
owners = append(owners, id)
}
if len(owners) == 0 {
return out, nil
}
// 批量取 rules:存在的行进 map,缺失的 (owner,key) 用 defaultRules(复刻 GetRules 兜底)。
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
if s != nil && s.rules != nil {
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
if err != nil {
return nil, err
}
for _, r := range list {
if !ValidKey(r.Key) {
continue
}
if len(r.Rules) == 0 {
r.Rules = domain.DefaultPrivacyRules(r.Key)
}
if rulesByOwner[r.OwnerUserID] == nil {
rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
}
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
// contacts.Get(owner, viewer))。
var reverse map[int64]domain.Contact
if s != nil && s.contacts != nil {
var err error
reverse, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
if err != nil {
return nil, err
}
}
for _, owner := range owners {
_, isContact := reverse[owner]
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
})
}
out[owner] = m
}
return out, nil
}
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
if len(ownerUserIDs) == 0 || len(viewerUserIDs) == 0 || len(keys) == 0 {
return out, nil
}
for _, k := range keys {
if !ValidKey(k) {
return nil, domain.ErrPrivacyKeyInvalid
}
}
owners := dedupNonZero(ownerUserIDs)
viewers := dedupNonZero(viewerUserIDs)
if len(owners) == 0 || len(viewers) == 0 {
return out, nil
}
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
if s != nil && s.rules != nil {
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
if err != nil {
return nil, err
}
for _, r := range list {
if !ValidKey(r.Key) {
continue
}
if len(r.Rules) == 0 {
r.Rules = domain.DefaultPrivacyRules(r.Key)
}
if rulesByOwner[r.OwnerUserID] == nil {
rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
}
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
for _, owner := range owners {
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
var ownerContacts map[int64]domain.Contact
if s != nil && s.contacts != nil {
var err error
ownerContacts, err = s.contacts.GetMany(ctx, owner, viewers)
if err != nil {
return nil, err
}
}
perViewer := make(map[int64]map[domain.PrivacyKey]bool, len(viewers))
for _, viewer := range viewers {
m := make(map[domain.PrivacyKey]bool, len(keys))
if owner == viewer {
for _, k := range keys {
m[k] = true
}
perViewer[viewer] = m
continue
}
_, isContact := ownerContacts[viewer]
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
})
}
perViewer[viewer] = m
}
out[owner] = perViewer
}
return out, nil
}
func dedupNonZero(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
return true

View file

@ -73,3 +73,96 @@ func TestExplicitDisallowUserWins(t *testing.T) {
t.Fatal("explicit disallow user should win over allow all")
}
}
// TestCanSeeBatchEquivalentToCanSee 锁定批量 privacy 评估与逐 CanSee 字节等价(projectBatch
// fan-out N+1 优化的正确性前提):覆盖默认规则/allow-all/disallow-all/allow-contacts(含联系人)/self。
func TestCanSeeBatchEquivalentToCanSee(t *testing.T) {
ctx := context.Background()
contacts := memory.NewContactStore()
svc := NewService(memory.NewPrivacyStore(), contacts)
const viewer = int64(1002)
owners := []int64{1001, 1003, 1004, 1005, viewer}
if _, err := svc.SetRules(ctx, 1003, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
t.Fatalf("set 1003 phone: %v", err)
}
if _, err := svc.SetRules(ctx, 1004, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set 1004 status: %v", err)
}
if _, err := svc.SetRules(ctx, 1005, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set 1005 phone: %v", err)
}
// owner 1005 把 viewer 加为联系人(GetReverseContacts(viewer,[1005]) 命中 → allow-contacts 可见)。
if _, err := contacts.Upsert(ctx, 1005, domain.ContactInput{ContactUserID: viewer}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
batch, err := svc.CanSeeBatch(ctx, owners, viewer, keys)
if err != nil {
t.Fatalf("CanSeeBatch: %v", err)
}
for _, owner := range owners {
for _, k := range keys {
want, err := svc.CanSee(ctx, owner, viewer, k)
if err != nil {
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
}
got, ok := batch[owner][k]
if !ok {
t.Fatalf("CanSeeBatch missing owner=%d key=%v", owner, k)
}
if got != want {
t.Fatalf("CanSeeBatch[%d][%v]=%v != CanSee=%v (must be equivalent)", owner, k, got, want)
}
}
}
}
// TestCanSeeMatrixEquivalentToCanSee 锁定 owners×viewers×keys 矩阵评估与逐 CanSee 字节等价
// (ForViewers fan-out 模板化把 privacy 查询降到 O(owner) 的正确性前提)。覆盖多 owner 多 viewer:
// 不同规则、联系人方向(owner 把 viewer 加为联系人才命中 allow-contacts)、self(owner==viewer)。
func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
ctx := context.Background()
contacts := memory.NewContactStore()
svc := NewService(memory.NewPrivacyStore(), contacts)
owners := []int64{6001, 6002, 6003, 6004}
viewers := []int64{7001, 7002, 6002} // 6002 既是 owner 又是 viewer → 命中 self 分支
if _, err := svc.SetRules(ctx, 6002, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
t.Fatalf("set 6002 phone: %v", err)
}
if _, err := svc.SetRules(ctx, 6003, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set 6003 status: %v", err)
}
if _, err := svc.SetRules(ctx, 6004, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set 6004 phone: %v", err)
}
// owner 6004 把 viewer 7001 加为联系人(owner→viewer 方向 = privacy 的 ViewerIsContact)。
if _, err := contacts.Upsert(ctx, 6004, domain.ContactInput{ContactUserID: 7001}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, keys)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
for _, owner := range owners {
for _, viewer := range viewers {
for _, k := range keys {
want, err := svc.CanSee(ctx, owner, viewer, k)
if err != nil {
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
}
got, ok := matrix[owner][viewer][k]
if !ok {
t.Fatalf("CanSeeMatrix missing owner=%d viewer=%d key=%v", owner, viewer, k)
}
if got != want {
t.Fatalf("CanSeeMatrix[%d][%d][%v]=%v != CanSee=%v (must be equivalent)", owner, viewer, k, got, want)
}
}
}
}
}

View file

@ -0,0 +1,32 @@
package readmodel
import (
"encoding/binary"
"hash/fnv"
)
const (
ModelDialogLight = "dialog_light"
ModelContactAccount = "contact_account"
ModelChannelBase = "channel_base"
ModelChannelMember = "channel_member"
ModelChannelActiveIDs = "channel_active_memberships"
ModelChannelMediaCounts = "channel_media_counts"
ModelPrivateMediaCounts = "private_media_counts"
ModelChannelParticipants = "channel_participants"
ModelChannelSelfBoosts = "channel_self_boosts"
)
func MixHashes(values ...int64) int64 {
h := fnv.New64a()
var buf [8]byte
for _, value := range values {
binary.LittleEndian.PutUint64(buf[:], uint64(value))
_, _ = h.Write(buf[:])
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}

View file

@ -0,0 +1,55 @@
// Package secretchat 实现私聊端对端加密(Secret Chat / EncryptedChat)的握手
// 状态机。服务端是盲中继:g_a/g_b/key_fingerprint/加密 bytes 全部不透明存储与
// 原样转发,唯一参与密码学的点是对 g_a/g_b 做 DH 范围边界校验(防弱 DH/MITM)。
// 共享密钥与明文是 E2E 客户端职责,服务端不知道也无法计算。
// 设计见 docs/secret-chat-module.md。
package secretchat
import (
"errors"
"math/big"
appphone "telesrv/internal/app/phone"
)
// dhPubSize 是 g_a/g_b 的规范字节长度(2048-bit)。
const dhPubSize = 256
// ErrGAInvalid:g_a/g_b 不在合法 DH 区间 → rpc 层映射为 DH_G_A_INVALID。
var ErrGAInvalid = errors.New("secretchat: dh parameter invalid")
var (
// dhPrimeMinusOne/边界值复用 phone 域官方 2048-bit safe prime(与 getDhConfig 下发的 p 同源)。
dhPrimeMinusOne = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), big.NewInt(1))
// 2^(2048-64) 下界与 p-2^(2048-64) 上界(官方 isGoodPrime 同款边界)。
dhLowerBound = new(big.Int).Lsh(big.NewInt(1), 2048-64)
dhUpperBound = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), new(big.Int).Lsh(big.NewInt(1), 2048-64))
dhOne = big.NewInt(1)
)
// validateDHParam 对 g_a/g_b 做范围校验,通过后左补零到 256 字节返回(规范线格式;
// 首字节为 0 被裁的合法 g_a 不能误拒)。校验用的是 big.Int 值,与补零无关。
func validateDHParam(g []byte) ([]byte, error) {
if len(g) == 0 || len(g) > dhPubSize {
return nil, ErrGAInvalid
}
x := new(big.Int).SetBytes(g)
// 1 < x < p-1 且 2^(2048-64) < x < p-2^(2048-64)
if x.Cmp(dhOne) <= 0 || x.Cmp(dhPrimeMinusOne) >= 0 {
return nil, ErrGAInvalid
}
if x.Cmp(dhLowerBound) <= 0 || x.Cmp(dhUpperBound) >= 0 {
return nil, ErrGAInvalid
}
return leftPad256(g), nil
}
// leftPad256 左补零到 256 字节(输入已保证 ≤256)。
func leftPad256(b []byte) []byte {
if len(b) == dhPubSize {
return append([]byte(nil), b...)
}
out := make([]byte, dhPubSize)
copy(out[dhPubSize-len(b):], b)
return out
}

View file

@ -0,0 +1,320 @@
package secretchat
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// idAllocRetries 是 chat_id 撞键自愈的有界重试次数。
const idAllocRetries = 4
// Service 实现密聊握手状态机 + qts 消息投递。所有返回的 domain.SecretChat 都是当时快照。
// 访问校验(self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、id/access_hash 分配、
// 状态机迁移与 qts 队列写入。绑定维度是设备级 perm auth_key(int64)。
type Service struct {
store store.SecretChatStore
queue store.EncryptedQueueStore
ids store.SecretChatIDAllocator
}
// NewService 创建密聊服务。
func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore, ids store.SecretChatIDAllocator) *Service {
return &Service{store: st, queue: queue, ids: ids}
}
// RequestEncryption 受理 requestEncryption:校验 g_a → 幂等去重 → 分配 chat_id + 双
// access_hash → 盲存 g_a → 落 requested 态。返回的密聊由 rpc 层投影为 admin 视角
// encryptedChatWaiting(同步响应)与 participant 视角 encryptedChatRequested(推送)。
func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error) {
if req.AdminUserID == 0 || req.ParticipantUserID == 0 || req.AdminAuthKeyID == 0 {
return domain.SecretChat{}, ErrGAInvalid
}
ga, err := validateDHParam(req.GA)
if err != nil {
return domain.SecretChat{}, err
}
// 幂等:同发起设备 + random_id 重发返回既有 chat(DISCARDED 视为新请求)。
if existing, ok, err := s.store.GetByAdminRandom(ctx, req.AdminAuthKeyID, req.RandomID); err != nil {
return domain.SecretChat{}, err
} else if ok && !existing.Terminal() {
return existing, nil
}
adminAH, err := randomAccessHash()
if err != nil {
return domain.SecretChat{}, err
}
participantAH, err := randomAccessHash()
if err != nil {
return domain.SecretChat{}, err
}
chat := domain.SecretChat{
AdminAccessHash: adminAH,
ParticipantAccessHash: participantAH,
AdminUserID: req.AdminUserID,
ParticipantUserID: req.ParticipantUserID,
AdminAuthKeyID: req.AdminAuthKeyID,
State: domain.SecretChatStateRequested,
GA: ga,
RandomID: req.RandomID,
Date: req.Date,
}
for attempt := 0; ; attempt++ {
chatID, err := s.nextChatID(ctx, attempt)
if err != nil {
return domain.SecretChat{}, err
}
chat.ID = chatID
err = s.store.CreateSecretChat(ctx, chat)
if err == nil {
return chat, nil
}
if errors.Is(err, domain.ErrSecretChatIDConflict) && attempt < idAllocRetries {
continue
}
return domain.SecretChat{}, err
}
}
// nextChatID 分配下一个 chat_id;撞键后用 AtLeast(MaxSecretChatID) 顶起计数器自愈。
// 校验 int32 正区间上界(EncryptedChat.ID 是 int32 量级)。
func (s *Service) nextChatID(ctx context.Context, attempt int) (int, error) {
var (
id int
err error
)
if attempt == 0 {
id, err = s.ids.NextSecretChatID(ctx)
} else {
floor, ferr := s.store.MaxSecretChatID(ctx)
if ferr != nil {
return 0, ferr
}
id, err = s.ids.NextSecretChatIDAtLeast(ctx, floor)
}
if err != nil {
return 0, err
}
if id <= 0 || id > 0x7fffffff {
return 0, fmt.Errorf("secretchat: chat id out of int32 range: %d", id)
}
return id, nil
}
// AcceptEncryption 受理 acceptEncryption:定位 + participant 视角 access_hash 校验 →
// 校验 g_b → 原子 CAS 绑定接受设备并落 g_b/key_fingerprint → normal。返回的密聊由
// rpc 层投影为 participant 视角 encryptedChat(GAOrB=g_a,同步响应)与 admin 视角
// encryptedChat(GAOrB=g_b,推送)。
func (s *Service) AcceptEncryption(ctx context.Context, chatID int, viewerUserID, participantAuthKeyID, accessHash int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) {
gbPadded, err := validateDHParam(gb)
if err != nil {
return domain.SecretChat{}, err
}
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, err
}
// 视角校验:调用方必须是接受方本人且 access_hash 匹配 participant 视角。
if !ok || chat.ParticipantUserID != viewerUserID || chat.ParticipantAccessHash != accessHash {
return domain.SecretChat{}, domain.ErrSecretChatNotFound
}
switch chat.State {
case domain.SecretChatStateNormal:
return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted
case domain.SecretChatStateDiscarded:
return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined
}
return s.store.AcceptSecretChat(ctx, chatID, participantAuthKeyID, gbPadded, keyFingerprint)
}
// DiscardEncryption 受理 discardEncryption:定位 + 参与者校验 → 迁移到 discarded。
// already=true 表示已是终态(幂等成功)。返回的密聊由 rpc 层投影为对端
// encryptedChatDiscarded 推送。
func (s *Service) DiscardEncryption(ctx context.Context, chatID int, viewerUserID int64, deleteHistory bool) (domain.SecretChat, bool, error) {
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, false, err
}
if !ok || !chat.HasParticipant(viewerUserID) {
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
}
return s.store.DiscardSecretChat(ctx, chatID, deleteHistory)
}
// GetSecretChat 取密聊快照(rpc 层访问校验用)。
func (s *Service) GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) {
return s.store.GetSecretChat(ctx, chatID)
}
// DiscardForAuthKey 级联 discard 绑定该设备 perm auth_key(作为 admin 或 participant)的
// 全部活跃密聊,用于设备登出 / 授权撤销。返回本次实际从非终态迁移到 discarded 的密聊快照
// (已是终态的不返回),供 rpc 层据此向对端推送 encryptedChatDiscarded。盲中继:不删历史
// (history_deleted=false,对端自行决定本地处置)。出错时返回已成功 discard 的部分 + err,
// 让调用方仍能通知这部分对端(登出/撤销是 best-effort,不因此回退)。
func (s *Service) DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) {
if authKeyID == 0 {
return nil, nil
}
chats, err := s.store.ListActiveSecretChatsByAuthKey(ctx, authKeyID)
if err != nil {
return nil, err
}
var discarded []domain.SecretChat
for _, c := range chats {
updated, already, derr := s.store.DiscardSecretChat(ctx, c.ID, false)
if derr != nil {
if errors.Is(derr, domain.ErrSecretChatNotFound) {
continue
}
return discarded, derr
}
if !already {
discarded = append(discarded, updated)
}
}
return discarded, nil
}
// SendEncrypted 受理 sendEncrypted*:定位 + 发送方视角 access_hash 校验 + 态须 normal →
// 给【对端绑定设备】分配 qts 并把不透明 bytes 写入投递队列(幂等:同 chat+random_id 返既有
// qts/date)。返回密聊快照 + 已落库消息(携 qts/date,rpc 层据此推 updateNewEncryptedMessage
// 并回 SentEncryptedMessage{date})。盲中継:不解密 bytes。
func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) {
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, domain.SecretChatMessage{}, err
}
if !ok || !chat.HasParticipant(viewerUserID) || chat.AccessHashFor(viewerUserID) != accessHash {
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
if chat.State != domain.SecretChatStateNormal {
// 未成型 / 已销毁的密聊不能收发(CHAT_ID_INVALID)。
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
receiverUserID := chat.PeerOf(viewerUserID)
receiverAuthKeyID := chat.AdminAuthKeyID
if chat.IsAdmin(viewerUserID) {
receiverAuthKeyID = chat.ParticipantAuthKeyID
}
if receiverUserID == 0 || receiverAuthKeyID == 0 {
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
stored, _, err := s.queue.AppendEncryptedMessage(ctx, domain.SecretChatMessage{
ReceiverAuthKeyID: receiverAuthKeyID,
ReceiverUserID: receiverUserID,
ChatID: chatID,
RandomID: delivery.RandomID,
Date: delivery.Date,
IsService: delivery.IsService,
Bytes: delivery.Bytes,
File: delivery.File,
})
if err != nil {
return domain.SecretChat{}, domain.SecretChatMessage{}, err
}
return chat, stored, nil
}
// ListNewMessages 返回某设备 qts > sinceQts 的连续加密消息(getDifference 补差分用)。
func (s *Service) ListNewMessages(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) {
if deviceAuthKeyID == 0 {
return nil, nil
}
return s.queue.ListEncryptedMessagesSince(ctx, deviceAuthKeyID, sinceQts, limit)
}
// DeviceReservedQts 返回某设备当前已分配的最高 qts(getState 用)。
func (s *Service) DeviceReservedQts(ctx context.Context, deviceAuthKeyID int64) (int, error) {
if deviceAuthKeyID == 0 {
return 0, nil
}
return s.queue.ReservedQts(ctx, deviceAuthKeyID)
}
// AckQueue 推进某设备的 confirmed qts 并标记 acked(receivedQueue)。
func (s *Service) AckQueue(ctx context.Context, deviceAuthKeyID int64, maxQts int) error {
if deviceAuthKeyID == 0 || maxQts <= 0 {
return nil
}
return s.queue.AckEncryptedMessages(ctx, deviceAuthKeyID, maxQts)
}
// RecordEncryptionEvent 写入 durable updateEncryption 状态事件(离线补偿)。
// targetAuthKeyID=0 表示账号级(建链前邀请/撤回对 target 所有设备可见),非 0 表示
// 绑定设备定向。投递时按 secret_chats 权威态重建(不固化快照)。
func (s *Service) RecordEncryptionEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, date int) error {
if targetUserID == 0 {
return nil
}
_, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{
TargetUserID: targetUserID,
TargetAuthKeyID: targetAuthKeyID,
ChatID: chatID,
Type: domain.EncryptedStateEventEncryption,
Date: date,
})
return err
}
// RecordReadEvent 写入 durable updateEncryptedMessagesRead 状态事件(离线补偿,设备定向)。
func (s *Service) RecordReadEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, maxDate, date int) error {
if targetUserID == 0 {
return nil
}
_, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{
TargetUserID: targetUserID,
TargetAuthKeyID: targetAuthKeyID,
ChatID: chatID,
Type: domain.EncryptedStateEventRead,
MaxDate: maxDate,
Date: date,
})
return err
}
// ListStateEvents 返回某设备未投递的密聊状态事件(getDifference 补偿用)。
func (s *Service) ListStateEvents(ctx context.Context, userID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) {
if userID == 0 || deviceAuthKeyID == 0 {
return nil, nil
}
return s.queue.ListUndeliveredStateEvents(ctx, userID, deviceAuthKeyID, limit)
}
// MarkStateEventsDelivered 登记某设备已投递这些状态事件。
func (s *Service) MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error {
if deviceAuthKeyID == 0 || len(eventIDs) == 0 {
return nil
}
return s.queue.MarkStateEventsDelivered(ctx, deviceAuthKeyID, eventIDs)
}
// PutEncryptedFile 持久化密聊文件元数据快照(铸造后写一次)。
func (s *Service) PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error {
if ref.ID == 0 {
return nil
}
return s.queue.PutEncryptedFile(ctx, ownerUserID, ref)
}
// GetEncryptedFile 按 id + access_hash 回查文件快照(inputEncryptedFile 复用路径)。
func (s *Service) GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) {
return s.queue.GetEncryptedFile(ctx, id, accessHash)
}
// randomAccessHash 生成正 int64 access_hash(rand 8B → 高位清零保正,0 置 1)。
func randomAccessHash() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("secretchat: access hash rand: %w", err)
}
v := int64(binary.BigEndian.Uint64(b[:]) >> 1)
if v == 0 {
v = 1
}
return v, nil
}

View file

@ -0,0 +1,346 @@
package secretchat
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeChatIDAllocator 是单调自增的测试分配器(无 Redis)。
type fakeChatIDAllocator struct{ n int }
func (a *fakeChatIDAllocator) NextSecretChatID(context.Context) (int, error) {
a.n++
return a.n, nil
}
func (a *fakeChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) {
if a.n < floor {
a.n = floor
}
a.n++
return a.n, nil
}
func (a *fakeChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil }
// validGA 返回一个落在合法 DH 区间的 256 字节 g_a(首字节 0x55 ≈ 2^2046,
// 既 > 2^1984 又 < p≈0xc7..)。
func validGA() []byte {
b := make([]byte, 256)
for i := range b {
b[i] = 0x42
}
b[0] = 0x55
return b
}
func newTestService() (*Service, *memory.SecretChatStore) {
st := memory.NewSecretChatStore()
return NewService(st, memory.NewEncryptedQueueStore(), &fakeChatIDAllocator{}), st
}
const (
adminUser = int64(1001)
partUser = int64(2002)
adminAuthKey = int64(0x1111)
partAuthKey = int64(0x2222)
keyFP = int64(0x0123456789abcdef)
)
func requestFixture() domain.SecretChatRequest {
return domain.SecretChatRequest{
AdminUserID: adminUser,
AdminAuthKeyID: adminAuthKey,
ParticipantUserID: partUser,
RandomID: 12345,
GA: validGA(),
Date: 1000,
}
}
func TestRequestEncryption(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, err := svc.RequestEncryption(ctx, requestFixture())
if err != nil {
t.Fatalf("RequestEncryption: %v", err)
}
if chat.ID <= 0 || chat.ID > 0x7fffffff {
t.Fatalf("chat id out of int32 range: %d", chat.ID)
}
if chat.State != domain.SecretChatStateRequested {
t.Fatalf("state = %q, want requested", chat.State)
}
if len(chat.GA) != dhPubSize {
t.Fatalf("g_a length = %d, want %d (left-padded)", len(chat.GA), dhPubSize)
}
if chat.AdminAccessHash == 0 || chat.ParticipantAccessHash == 0 {
t.Fatal("access hashes must be non-zero")
}
if chat.AdminAccessHash == chat.ParticipantAccessHash {
t.Fatal("admin/participant access hashes must differ (per-viewer)")
}
if chat.ParticipantAuthKeyID != 0 {
t.Fatalf("participant auth key must be unbound before accept, got %d", chat.ParticipantAuthKeyID)
}
}
func TestRequestEncryptionIdempotent(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
first, err := svc.RequestEncryption(ctx, requestFixture())
if err != nil {
t.Fatalf("first request: %v", err)
}
second, err := svc.RequestEncryption(ctx, requestFixture())
if err != nil {
t.Fatalf("second request: %v", err)
}
if first.ID != second.ID {
t.Fatalf("idempotent re-request must return same chat: %d vs %d", first.ID, second.ID)
}
}
func TestRequestEncryptionInvalidGA(t *testing.T) {
svc, _ := newTestService()
req := requestFixture()
req.GA = []byte{0x01} // value 1 → 不在 (1, p-1)
if _, err := svc.RequestEncryption(context.Background(), req); !errors.Is(err, ErrGAInvalid) {
t.Fatalf("err = %v, want ErrGAInvalid", err)
}
}
func TestAcceptEncryption(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, err := svc.RequestEncryption(ctx, requestFixture())
if err != nil {
t.Fatalf("request: %v", err)
}
accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
if err != nil {
t.Fatalf("accept: %v", err)
}
if accepted.State != domain.SecretChatStateNormal {
t.Fatalf("state = %q, want normal", accepted.State)
}
if accepted.KeyFingerprint != keyFP {
t.Fatalf("key fingerprint not relayed byte-for-byte: got %x want %x", accepted.KeyFingerprint, keyFP)
}
if accepted.ParticipantAuthKeyID != partAuthKey {
t.Fatalf("participant auth key not bound: %d", accepted.ParticipantAuthKeyID)
}
if len(accepted.GB) != dhPubSize {
t.Fatalf("g_b length = %d, want %d", len(accepted.GB), dhPubSize)
}
}
func TestAcceptEncryptionWrongAccessHash(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash+1, validGA(), keyFP)
if !errors.Is(err, domain.ErrSecretChatNotFound) {
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
}
}
func TestAcceptEncryptionWrongUser(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
// admin 自己冒充接受方。
_, err := svc.AcceptEncryption(ctx, chat.ID, adminUser, adminAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
if !errors.Is(err, domain.ErrSecretChatNotFound) {
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
}
}
func TestAcceptEncryptionDoubleAccept(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
if _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP); err != nil {
t.Fatalf("first accept: %v", err)
}
// 第二台设备 accept:CAS 落空 → ENCRYPTION_ALREADY_ACCEPTED。
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, int64(0x3333), chat.ParticipantAccessHash, validGA(), keyFP)
if !errors.Is(err, domain.ErrSecretChatAlreadyAccepted) {
t.Fatalf("err = %v, want ErrSecretChatAlreadyAccepted", err)
}
}
func TestAcceptEncryptionInvalidGB(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, []byte{0x01}, keyFP)
if !errors.Is(err, ErrGAInvalid) {
t.Fatalf("err = %v, want ErrGAInvalid", err)
}
}
func TestDiscardEncryption(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
got, already, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, true)
if err != nil {
t.Fatalf("discard: %v", err)
}
if already {
t.Fatal("first discard must not report already")
}
if got.State != domain.SecretChatStateDiscarded || !got.HistoryDeleted {
t.Fatalf("discarded chat = %+v", got)
}
// 幂等:再 discard 返回 already=true。
_, already, err = svc.DiscardEncryption(ctx, chat.ID, partUser, false)
if err != nil || !already {
t.Fatalf("idempotent discard: already=%v err=%v", already, err)
}
}
func TestDiscardEncryptionNonParticipant(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
_, _, err := svc.DiscardEncryption(ctx, chat.ID, int64(9999), false)
if !errors.Is(err, domain.ErrSecretChatNotFound) {
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
}
}
// acceptedChat 跑完 request→accept,返回 normal 态密聊。
func acceptedChat(t *testing.T, svc *Service) domain.SecretChat {
t.Helper()
ctx := context.Background()
chat, err := svc.RequestEncryption(ctx, requestFixture())
if err != nil {
t.Fatalf("request: %v", err)
}
accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, dhParamGB(), keyFP)
if err != nil {
t.Fatalf("accept: %v", err)
}
return accepted
}
func dhParamGB() []byte {
b := make([]byte, 256)
for i := range b {
b[i] = 0x42
}
b[0] = 0x66
return b
}
func TestSendEncryptedQtsAllocation(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat := acceptedChat(t, svc)
// admin 发 → 投给 participant 设备(partAuthKey),qts 从 1 起。
_, m1, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 2000})
if err != nil {
t.Fatalf("send 1: %v", err)
}
if m1.Qts != 1 || m1.ReceiverAuthKeyID != partAuthKey || m1.ReceiverUserID != partUser {
t.Fatalf("msg1 = %+v (want qts=1, receiver=participant device)", m1)
}
_, m2, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 222, Bytes: []byte{4}, Date: 2001})
if err != nil || m2.Qts != 2 {
t.Fatalf("msg2 qts = %d err=%v, want 2", m2.Qts, err)
}
// 幂等重发同 random_id → 返回首次 qts/date,不分配新 qts。
_, dup, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 9999})
if err != nil {
t.Fatalf("dup send: %v", err)
}
if dup.Qts != 1 || dup.Date != 2000 {
t.Fatalf("idempotent resend = %+v, want qts=1 date=2000 (首次落库值)", dup)
}
// participant 发 → 投给 admin 设备(adminAuthKey),独立 qts 序列从 1 起。
_, pm, err := svc.SendEncrypted(ctx, chat.ID, partUser, chat.ParticipantAccessHash, domain.SecretMessageDelivery{RandomID: 333, Bytes: []byte{9}, Date: 2002})
if err != nil {
t.Fatalf("participant send: %v", err)
}
if pm.Qts != 1 || pm.ReceiverAuthKeyID != adminAuthKey || pm.ReceiverUserID != adminUser {
t.Fatalf("participant msg = %+v (want qts=1, receiver=admin device)", pm)
}
}
func TestSendEncryptedWrongAccessHash(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat := acceptedChat(t, svc)
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash+1, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
if !errors.Is(err, domain.ErrSecretChatNotFound) {
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
}
}
func TestSendEncryptedNonNormal(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture()) // requested, 未 accept
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
if !errors.Is(err, domain.ErrSecretChatNotFound) {
t.Fatalf("err = %v, want ErrSecretChatNotFound (未成型不能发)", err)
}
}
func TestListNewMessagesAndAck(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat := acceptedChat(t, svc)
for i := 0; i < 3; i++ {
if _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: int64(1000 + i), Bytes: []byte{byte(i)}, Date: 2000 + i}); err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
// 接收设备(participant)补差分:qts>0 全部 3 条。
msgs, err := svc.ListNewMessages(ctx, partAuthKey, 0, 0)
if err != nil || len(msgs) != 3 {
t.Fatalf("list since 0 = %d msgs err=%v, want 3", len(msgs), err)
}
if msgs[0].Qts != 1 || msgs[2].Qts != 3 {
t.Fatalf("qts sequence broken: %d..%d", msgs[0].Qts, msgs[2].Qts)
}
// qts>1 → 剩 2 条。
msgs, _ = svc.ListNewMessages(ctx, partAuthKey, 1, 0)
if len(msgs) != 2 || msgs[0].Qts != 2 {
t.Fatalf("list since 1 = %+v, want qts 2,3", msgs)
}
// reserved qts = 3。
if q, _ := svc.DeviceReservedQts(ctx, partAuthKey); q != 3 {
t.Fatalf("reserved qts = %d, want 3", q)
}
// ack 到 3:不报错(confirmed 推进)。
if err := svc.AckQueue(ctx, partAuthKey, 3); err != nil {
t.Fatalf("ack: %v", err)
}
// 未参与设备 qts=0。
if q, _ := svc.DeviceReservedQts(ctx, int64(0xDEAD)); q != 0 {
t.Fatalf("unrelated device reserved qts = %d, want 0", q)
}
}
func TestAcceptAfterDiscard(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
chat, _ := svc.RequestEncryption(ctx, requestFixture())
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, false); err != nil {
t.Fatalf("discard: %v", err)
}
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
if !errors.Is(err, domain.ErrSecretChatAlreadyDeclined) {
t.Fatalf("err = %v, want ErrSecretChatAlreadyDeclined", err)
}
}

View file

@ -0,0 +1,124 @@
// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的
// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText),
// 本层只管目录与持久化。
package stargifts
import (
"context"
"sync"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// CatalogProvider 合成礼物目录(app/files 实现)。
type CatalogProvider interface {
BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error)
}
// Service 是 Star 礼物应用服务。
type Service struct {
store store.StarGiftStore
catalog CatalogProvider
mu sync.Mutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
}
// NewService 创建 Star 礼物服务。
func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service {
return &Service{store: st, catalog: catalog}
}
// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。
func (s *Service) ensureCatalog(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.built {
return nil
}
gifts, err := s.catalog.BuildStarGiftCatalog(ctx)
if err != nil {
return err
}
s.gifts = gifts
s.byID = make(map[int64]domain.StarGift, len(gifts))
for _, g := range gifts {
s.byID[g.ID] = g
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
// Catalog 返回礼物目录。
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.StarGift, len(s.gifts))
copy(out, s.gifts)
return out, nil
}
// CatalogHash 返回目录 hash(getStarGifts NotModified 判定)。
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.hash, nil
}
// GiftByID 返回目录中指定礼物,不存在返回 ok=false。
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
if err := s.ensureCatalog(ctx); err != nil {
return domain.StarGift{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.byID[id]
return g, ok, nil
}
// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
return s.store.Create(ctx, gift)
}
// ListSaved 分页返回某 owner 收到的礼物。
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
if len(offset) > domain.MaxStarGiftsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit)
}
// GetSaved 按协议引用取礼物实例。
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
// CountSaved 返回某 owner 展示在资料的礼物数(full.stargifts_count)。
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
return s.store.CountByOwner(ctx, owner)
}
// ToggleSaved 切换礼物在资料的展示(saveStarGift)。
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert 把礼物标记为已转换(convertStarGift),返回该行供调用方据 ConvertStars 入账。
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}

View file

@ -0,0 +1,150 @@
package stargifts
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeCatalog struct {
gifts []domain.StarGift
calls int
}
func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
f.calls++
return f.gifts, nil
}
func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) {
cat := &fakeCatalog{gifts: gifts}
return NewService(memory.NewStarGiftStore(), cat), cat
}
func TestCatalogCachedAndHash(t *testing.T) {
gifts := []domain.StarGift{
{ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"},
}
svc, cat := newTestService(gifts)
ctx := context.Background()
got, err := svc.Catalog(ctx)
if err != nil || len(got) != 2 {
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
}
// 再取一次不重新构建(缓存)。
if _, err := svc.Catalog(ctx); err != nil {
t.Fatalf("catalog#2: %v", err)
}
if cat.calls != 1 {
t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls)
}
hash, err := svc.CatalogHash(ctx)
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
}
if g, ok, _ := svc.GiftByID(ctx, 2); !ok || g.Stars != 50 {
t.Fatalf("GiftByID(2) = %+v ok %v, want Cake 50", g, ok)
}
if _, ok, _ := svc.GiftByID(ctx, 999); ok {
t.Fatalf("GiftByID(999) found, want missing")
}
}
func TestSavedGiftLifecycle(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15,
})
if err != nil || id == 0 {
t.Fatalf("RecordSavedGift = %d err %v", id, err)
}
page, err := svc.ListSaved(ctx, owner, false, "", 100)
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
t.Fatalf("list = %d count %d err %v, want 1/1", len(page.Gifts), page.Count, err)
}
if page.NextOffset != "" {
t.Fatalf("single page next_offset = %q, want empty", page.NextOffset)
}
// 隐藏(unsave=true)→ excludeUnsaved 列表为空。
ref := domain.SavedStarGiftRef{Owner: owner, MsgID: 50}
if ok, err := svc.ToggleSaved(ctx, ref, true); err != nil || !ok {
t.Fatalf("ToggleSaved hide = %v err %v", ok, err)
}
hidden, _ := svc.ListSaved(ctx, owner, true, "", 100)
if len(hidden.Gifts) != 0 {
t.Fatalf("excludeUnsaved list = %d, want 0 after hide", len(hidden.Gifts))
}
// 不带 exclude 仍能看到。
all, _ := svc.ListSaved(ctx, owner, false, "", 100)
if len(all.Gifts) != 1 {
t.Fatalf("full list = %d, want 1 (hidden still listed)", len(all.Gifts))
}
// 转换回 Stars → 标记 converted,从列表消失。
saved, err := svc.Convert(ctx, ref)
if err != nil || saved.ConvertStars != 15 {
t.Fatalf("Convert = %+v err %v, want ConvertStars 15", saved, err)
}
after, _ := svc.ListSaved(ctx, owner, false, "", 100)
if len(after.Gifts) != 0 {
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
}
// 重复转换被拒。
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
}
}
func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0,
Date: 1700000000, ConvertStars: 15,
})
if err != nil || savedID == 0 {
t.Fatalf("RecordSavedGift(channel) = %d err %v, want allocated saved_id", savedID, err)
}
gift, found, err := svc.GetSaved(ctx, domain.SavedStarGiftRef{Owner: owner, SavedID: savedID})
if err != nil || !found {
t.Fatalf("GetSaved(channel) found=%v err=%v, want hit", found, err)
}
if gift.MsgID != 0 || gift.SavedID != savedID {
t.Fatalf("channel saved gift ids = msg_id %d saved_id %d, want 0/%d", gift.MsgID, gift.SavedID, savedID)
}
}
func TestSavedGiftPagination(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
for i := 0; i < 5; i++ {
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
}); err != nil {
t.Fatalf("record#%d: %v", i, err)
}
}
page1, _ := svc.ListSaved(ctx, owner, false, "", 2)
if len(page1.Gifts) != 2 || page1.NextOffset == "" {
t.Fatalf("page1 = %d next=%q, want 2 + next", len(page1.Gifts), page1.NextOffset)
}
page2, _ := svc.ListSaved(ctx, owner, false, page1.NextOffset, 2)
page3, _ := svc.ListSaved(ctx, owner, false, page2.NextOffset, 2)
if len(page3.Gifts) != 1 || page3.NextOffset != "" {
t.Fatalf("page3 = %d next=%q, want 1 + empty (terminal)", len(page3.Gifts), page3.NextOffset)
}
}

View file

@ -0,0 +1,93 @@
// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页,
// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、
// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。
package stars
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 是 Stars 账本应用服务。
type Service struct {
store store.StarsStore
grantAmount int64
now func() time.Time
}
// Option 配置 Service。
type Option func(*Service)
// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。
func WithStartingGrant(amount int64) Option {
return func(s *Service) { s.grantAmount = amount }
}
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。
func NewService(st store.StarsStore, opts ...Option) *Service {
s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now}
for _, opt := range opts {
opt(s)
}
return s
}
// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。
func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) {
if s.grantAmount > 0 {
bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix()))
return bal, err
}
return s.store.GetBalance(ctx, userID)
}
// GetBalance 返回账号余额,首读时惰性授予起始余额。
func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
return s.ensureGranted(ctx, userID)
}
// Credit 为账号入账(amount>0)。
func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。
// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。
func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsBalance{}, err
}
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// ListTransactions 按 keyset 分页返回流水 + 当前余额,首读时惰性授予。
func (s *Service) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsTransactionPage{}, err
}
return s.store.ListTransactions(ctx, userID, offset, limit)
}

View file

@ -0,0 +1,137 @@
package stars
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newTestService(grant int64) *Service {
return NewService(memory.NewStarsStore(), WithStartingGrant(grant))
}
// 起始授予幂等:多次 GetBalance 只授予一次。
func TestStartingGrantOnce(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
bal, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 1000 || !bal.Granted {
t.Fatalf("first balance = %+v, want 1000 granted", bal)
}
// 再读不应重复授予。
bal2, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance#2: %v", err)
}
if bal2.Balance != 1000 {
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
}
// 流水里应恰有一条 grant。
page, err := svc.ListTransactions(ctx, 7, "", 100)
if err != nil {
t.Fatalf("ListTransactions: %v", err)
}
if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 {
t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions)
}
}
// 关闭授予(grant=0)时余额为 0、无 grant 流水。
func TestGrantDisabled(t *testing.T) {
svc := newTestService(0)
bal, err := svc.GetBalance(context.Background(), 9)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 0 || bal.Granted {
t.Fatalf("balance = %+v, want 0 not granted", bal)
}
}
// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。
func TestDebitAndInsufficient(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555}
bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "")
if err != nil {
t.Fatalf("Debit: %v", err)
}
if bal.Balance != 700 {
t.Fatalf("after debit = %d, want 700", bal.Balance)
}
// 余额不足。
if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
}
// 余额未被改动。
after, _ := svc.GetBalance(ctx, 7)
if after.Balance != 700 {
t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance)
}
// 非法金额。
if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) {
t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err)
}
}
// 贷记增加余额并写正流水。
func TestCredit(t *testing.T) {
svc := newTestService(0) // 关闭起始授予,单测贷记
ctx := context.Background()
bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "")
if err != nil {
t.Fatalf("Credit: %v", err)
}
if bal.Balance != 250 {
t.Fatalf("after credit = %d, want 250", bal.Balance)
}
}
// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。
func TestListTransactionsPagination(t *testing.T) {
svc := newTestService(0)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("Credit#%d: %v", i, err)
}
}
page1, err := svc.ListTransactions(ctx, 7, "", 2)
if err != nil {
t.Fatalf("page1: %v", err)
}
if len(page1.Transactions) != 2 || page1.NextOffset == "" {
t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset)
}
// 倒序:最新(id 最大,amount=14)在前。
if page1.Transactions[0].Amount != 14 {
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
}
page2, err := svc.ListTransactions(ctx, 7, page1.NextOffset, 2)
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2.Transactions) != 2 {
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
}
page3, err := svc.ListTransactions(ctx, 7, page2.NextOffset, 2)
if err != nil {
t.Fatalf("page3: %v", err)
}
if len(page3.Transactions) != 1 {
t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions))
}
if page3.NextOffset != "" {
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
}
}

View file

@ -0,0 +1,2 @@
// Package stories contains protocol-neutral story application services.
package stories

View file

@ -0,0 +1,560 @@
package stories
import (
"context"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service owns Telegram story read/watch state.
type Service struct {
stories store.StoryStore
channelAccess ChannelStoryAccess
}
// ChannelStoryAccess validates channel story mutation rights without leaking TL
// types into the app layer.
type ChannelStoryAccess interface {
CanPostStory(ctx context.Context, userID, channelID int64) error
CanEditStory(ctx context.Context, userID, channelID int64) error
CanDeleteStory(ctx context.Context, userID, channelID int64) error
CanPinStory(ctx context.Context, userID, channelID int64) error
}
// Option adjusts optional story service dependencies.
type Option func(*Service)
// WithChannelStoryAccess enables channel story publishing/admin checks.
func WithChannelStoryAccess(access ChannelStoryAccess) Option {
return func(s *Service) { s.channelAccess = access }
}
// NewService creates a story service.
func NewService(stories store.StoryStore, opts ...Option) *Service {
s := &Service{stories: stories}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerPost); err != nil {
return domain.StoryCreateResult{}, err
}
if req.RandomID == 0 {
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
}
if req.Media == nil {
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
}
req.Period = normalizeStoryPeriod(req.Period)
if req.Period == 0 {
return domain.StoryCreateResult{}, domain.ErrStoryPeriodInvalid
}
if s == nil || s.stories == nil {
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
}
return s.stories.CreateStory(ctx, req)
}
// UpsertStory stores a story snapshot. It is used by tests now and by the
// publishing flow once sendStory/editStory are promoted from stubs.
func (s *Service) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) {
if s == nil || s.stories == nil {
return domain.Story{}, domain.ErrStoryNotFound
}
return s.stories.UpsertStory(ctx, req)
}
func (s *Service) GetAllStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) {
return s.GetAllStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit)
}
func (s *Service) GetAllStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
if cursor.Set {
if err := validateStoryListCursor(cursor); err != nil {
return domain.StoryList{}, domain.ErrStoryOffsetInvalid
}
}
return s.stories.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, cursor, clampStoryLimit(limit))
}
func (s *Service) GetAllStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryListDigest{}, nil
}
return s.stories.ActiveStoriesDigest(ctx, viewerUserID, hidden, now)
}
func (s *Service) ListOwnerActiveStories(ctx context.Context, userID int64, owner domain.Peer, now, limit int) (domain.StoryList, error) {
if err := validateStoryOwner(userID, owner); err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryList{}, nil
}
return s.stories.ListOwnerActiveStories(ctx, owner, now, clampStoryLimit(limit))
}
func (s *Service) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.PeerStories{Peer: peer}, nil
}
return s.stories.GetPeerStories(ctx, viewerUserID, peer, now)
}
func (s *Service) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) {
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
return s.stories.GetStoriesByID(ctx, viewerUserID, peer, ids, now)
}
func (s *Service) GetStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerEdit); err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryList{}, nil
}
return s.stories.ListStoriesArchive(ctx, viewerUserID, peer, offsetID, clampStoryArchiveLimit(limit), now)
}
func (s *Service) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
return s.stories.ListPinnedStories(ctx, viewerUserID, peer, offsetID, clampStoryLimit(limit), now)
}
func (s *Service) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return false, nil
}
return s.stories.HasPinnedStories(ctx, viewerUserID, peer, now)
}
func (s *Service) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return nil, nil
}
return s.stories.ListReadStates(ctx, viewerUserID)
}
func (s *Service) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
out := make([]domain.RecentStory, len(peers))
for i, peer := range peers {
out[i].Peer = peer
}
return out, nil
}
return s.stories.GetPeerMaxIDs(ctx, viewerUserID, peers, now)
}
func (s *Service) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
return map[domain.Peer]bool{}, nil
}
return s.stories.GetPeerHiddenStates(ctx, viewerUserID, peers)
}
func (s *Service) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
out := make([]domain.PeerStoryProjection, len(peers))
for i, peer := range peers {
out[i].Peer = peer
out[i].Recent.Peer = peer
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
return out, nil
}
return s.stories.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
}
func (s *Service) ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
if maxID <= 0 || maxID > domain.MaxStoryID {
return domain.StoryReadResult{}, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
}
recent, err := s.stories.GetPeerMaxIDs(ctx, viewerUserID, []domain.Peer{peer}, date)
if err != nil {
return domain.StoryReadResult{}, err
}
if len(recent) == 0 || recent[0].MaxID <= 0 {
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
}
if maxID > recent[0].MaxID {
maxID = recent[0].MaxID
}
return s.stories.MarkRead(ctx, viewerUserID, peer, maxID, date)
}
func (s *Service) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) {
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return 0, err
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return 0, nil
}
return s.stories.IncrementViews(ctx, viewerUserID, peer, ids, date)
}
func (s *Service) SendReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) {
if storyID <= 0 || storyID > domain.MaxStoryID {
return domain.StoryReactionResult{}, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryReactionResult{ViewerID: viewerUserID, Peer: peer, StoryID: storyID, Reaction: reaction, Date: date}, nil
}
return s.stories.SetReaction(ctx, viewerUserID, peer, storyID, reaction, date)
}
func (s *Service) GetStoryViewsList(ctx context.Context, viewerUserID int64, req domain.StoryViewListRequest) (domain.StoryViewList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryViewList{}, err
}
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryViewList{}, domain.ErrStoryIDInvalid
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryViewList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryViewList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
req.Query = normalizeStoryViewQuery(req.Query)
return s.stories.ListStoryViews(ctx, req)
}
func (s *Service) GetStoryReactionsList(ctx context.Context, viewerUserID int64, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) {
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryReactionList{}, domain.ErrStoryIDInvalid
}
if req.Owner.Type == domain.PeerTypeChannel {
if viewerUserID == 0 || !req.CanViewOwnerInteractions {
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
}
} else {
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
}
if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil {
return domain.StoryReactionList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryReactionList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
return s.stories.ListStoryReactions(ctx, req)
}
func (s *Service) GetStoryPublicForwards(ctx context.Context, viewerUserID int64, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryPublicForwardList{}, err
}
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryPublicForwardList{}, domain.ErrStoryIDInvalid
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryPublicForwardList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryPublicForwardList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
return s.stories.ListStoryPublicForwards(ctx, req)
}
func (s *Service) CanViewStoryStats(ctx context.Context, userID int64, peer domain.Peer) error {
return s.authorizeStoryOwner(ctx, userID, peer, storyOwnerEdit)
}
func (s *Service) ListStoryViewerIDs(ctx context.Context, userID int64, owner domain.Peer, storyID, limit int) ([]int64, error) {
if err := validateStoryOwner(userID, owner); err != nil {
return nil, err
}
if storyID <= 0 || storyID > domain.MaxStoryID {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil {
return nil, nil
}
return s.stories.ListStoryViewerIDs(ctx, owner, storyID, clampStoryPrivacyFanoutLimit(limit))
}
func (s *Service) EditStory(ctx context.Context, userID int64, req domain.StoryEditRequest) (domain.StoryEditResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryEditResult{}, err
}
if req.ID <= 0 || req.ID > domain.MaxStoryID {
return domain.StoryEditResult{}, domain.ErrStoryIDInvalid
}
if !req.UpdateMedia && !req.UpdateCaption && !req.UpdatePrivacy && !req.UpdateMediaAreas {
return domain.StoryEditResult{}, domain.ErrStoryNotModified
}
if req.UpdateMedia && req.Media == nil {
return domain.StoryEditResult{}, domain.ErrStoryNotFound
}
if s == nil || s.stories == nil {
return domain.StoryEditResult{}, domain.ErrStoryNotFound
}
return s.stories.EditStory(ctx, req)
}
func (s *Service) DeleteStories(ctx context.Context, userID int64, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerDelete); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
if s == nil || s.stories == nil {
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
}
return s.stories.DeleteStories(ctx, peer, ids, date)
}
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDs(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
if s == nil || s.stories == nil {
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
}
return s.stories.TogglePinned(ctx, peer, ids, pinned, date)
}
func (s *Service) TogglePinnedToTop(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
return err
}
ids, err := normalizeStoryPinnedToTopIDs(ids)
if err != nil {
return err
}
if s == nil || s.stories == nil {
return nil
}
return s.stories.TogglePinnedToTop(ctx, peer, ids)
}
func (s *Service) TogglePeerStoriesHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error {
if s == nil || s.stories == nil || viewerUserID == 0 {
return nil
}
return s.stories.SetPeerHidden(ctx, viewerUserID, peer, hidden)
}
func (s *Service) CanSendStory(ctx context.Context, viewerUserID int64, peer domain.Peer) (int, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerPost); err != nil {
return 0, err
}
return domain.DefaultStoryCanSendRemaining, nil
}
type storyOwnerAction int
const (
storyOwnerPost storyOwnerAction = iota
storyOwnerEdit
storyOwnerDelete
storyOwnerPin
)
func (s *Service) authorizeStoryOwner(ctx context.Context, userID int64, peer domain.Peer, action storyOwnerAction) error {
if userID == 0 {
return domain.ErrStoryPeerInvalid
}
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID == userID {
return nil
}
case domain.PeerTypeChannel:
if peer.ID == 0 || s == nil || s.channelAccess == nil {
return domain.ErrStoryPeerInvalid
}
switch action {
case storyOwnerPost:
return s.channelAccess.CanPostStory(ctx, userID, peer.ID)
case storyOwnerEdit:
return s.channelAccess.CanEditStory(ctx, userID, peer.ID)
case storyOwnerDelete:
return s.channelAccess.CanDeleteStory(ctx, userID, peer.ID)
case storyOwnerPin:
return s.channelAccess.CanPinStory(ctx, userID, peer.ID)
}
}
return domain.ErrStoryPeerInvalid
}
func validateStoryIDs(ids []int) error {
if len(ids) > domain.MaxStoryIDs {
return domain.ErrStoryIDInvalid
}
for _, id := range ids {
if id <= 0 || id > domain.MaxStoryID {
return domain.ErrStoryIDInvalid
}
}
return nil
}
func validateStoryIDsNonEmpty(ids []int) error {
if len(ids) == 0 {
return domain.ErrStoryIDInvalid
}
return validateStoryIDs(ids)
}
func normalizeStoryIDsNonEmpty(ids []int) ([]int, error) {
if err := validateStoryIDsNonEmpty(ids); err != nil {
return nil, err
}
return normalizeStoryIDsUnchecked(ids), nil
}
func normalizeStoryIDs(ids []int) ([]int, error) {
if err := validateStoryIDs(ids); err != nil {
return nil, err
}
return normalizeStoryIDsUnchecked(ids), nil
}
func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) {
ids, err := normalizeStoryIDs(ids)
if err != nil {
return nil, err
}
if len(ids) > domain.MaxStoryPinnedToTop {
return nil, domain.ErrStoryIDInvalid
}
return ids, nil
}
func normalizeStoryIDsUnchecked(ids []int) []int {
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func validateStoryOwner(userID int64, peer domain.Peer) error {
if userID == 0 {
return domain.ErrStoryPeerInvalid
}
if peer.Type == domain.PeerTypeUser && peer.ID == userID {
return nil
}
return domain.ErrStoryPeerInvalid
}
func validateStoryListCursor(cursor domain.StoryListCursor) error {
if !cursor.Set {
return nil
}
if cursor.Date <= 0 || cursor.Peer.ID <= 0 {
return domain.ErrStoryOffsetInvalid
}
switch cursor.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return nil
default:
return domain.ErrStoryOffsetInvalid
}
}
func normalizeStoryPeriod(period int) int {
if period == 0 {
return domain.DefaultStoryPeriod
}
if validStoryPeriod(period) {
return period
}
return 0
}
func validStoryPeriod(period int) bool {
switch period {
case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod:
return true
default:
return false
}
}
func clampStoryLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryListLimit {
return domain.MaxStoryListLimit
}
return limit
}
func clampStoryArchiveLimit(limit int) int {
if limit == 0 {
return 0
}
if limit < 0 || limit > domain.MaxStoryListLimit {
return domain.MaxStoryListLimit
}
return limit
}
func clampStoryInteractionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
return domain.MaxStoryInteractionListLimit
}
return limit
}
func clampStoryPrivacyFanoutLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets {
return domain.MaxStoryPrivacyFanoutTargets
}
return limit
}
func normalizeStoryViewQuery(query string) string {
query = strings.TrimSpace(query)
runes := []rune(query)
if len(runes) > domain.MaxStoryViewQueryLength {
return string(runes[:domain.MaxStoryViewQueryLength])
}
return query
}

View file

@ -0,0 +1,313 @@
package stories
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestReadStoriesClampsFutureMaxIDToVisibleActiveStory(t *testing.T) {
ctx := context.Background()
store := memory.NewStoryStore()
service := NewService(store)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
viewerID := int64(2001)
for _, id := range []int{1, 2} {
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: id,
Date: 100 + id,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert story %d: %v", id, err)
}
}
read, err := service.ReadStories(ctx, viewerID, owner, 99, 200)
if err != nil {
t.Fatalf("read stories: %v", err)
}
if !read.Advanced || read.MaxReadID != 2 {
t.Fatalf("read = %+v, want advanced max 2", read)
}
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 3,
Date: 300,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert future story: %v", err)
}
peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400)
if err != nil {
t.Fatalf("get peer stories: %v", err)
}
if peerStories.MaxReadID != 2 || len(peerStories.Stories) != 3 {
t.Fatalf("peer stories = %+v, want max read 2 and three active stories", peerStories)
}
}
func TestReadStoriesWithoutVisibleActiveStoryDoesNotWriteFutureBoundary(t *testing.T) {
ctx := context.Background()
store := memory.NewStoryStore()
service := NewService(store)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
viewerID := int64(2001)
read, err := service.ReadStories(ctx, viewerID, owner, 99, 200)
if err != nil {
t.Fatalf("read stories: %v", err)
}
if read.Advanced || read.MaxReadID != 0 {
t.Fatalf("read = %+v, want no-op zero boundary", read)
}
states, err := store.ListReadStates(ctx, viewerID)
if err != nil {
t.Fatalf("list read states: %v", err)
}
if len(states) != 0 {
t.Fatalf("read states = %+v, want none", states)
}
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 1,
Date: 300,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert story after no-op read: %v", err)
}
peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400)
if err != nil {
t.Fatalf("get peer stories: %v", err)
}
if peerStories.MaxReadID != 0 || len(peerStories.Stories) != 1 {
t.Fatalf("peer stories = %+v, want unread first story", peerStories)
}
}
func TestReadStoriesWithoutStoreIsNoop(t *testing.T) {
ctx := context.Background()
service := NewService(nil)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
read, err := service.ReadStories(ctx, 2001, owner, 99, 200)
if err != nil {
t.Fatalf("read stories without store: %v", err)
}
if read.Advanced || read.MaxReadID != 0 || read.Peer != owner || read.ViewerID != 2001 {
t.Fatalf("read without store = %+v, want no-op zero boundary for viewer/peer", read)
}
}
func TestGetStoryReactionsListRequiresChannelInteractionGrant(t *testing.T) {
ctx := context.Background()
store := memory.NewStoryStore()
service := NewService(store)
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 1,
Date: 100,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
if _, err := store.SetReaction(ctx, 2001, owner, 1, &domain.MessageReaction{
Type: domain.MessageReactionEmoji,
Emoticon: "🔥",
}, 110); err != nil {
t.Fatalf("set reaction: %v", err)
}
_, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{
Owner: owner,
StoryID: 1,
Limit: 10,
})
if !errors.Is(err, domain.ErrStoryPeerInvalid) {
t.Fatalf("ungranted channel reaction list err = %v, want ErrStoryPeerInvalid", err)
}
list, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{
Owner: owner,
StoryID: 1,
Limit: 10,
CanViewOwnerInteractions: true,
})
if err != nil {
t.Fatalf("granted channel reaction list: %v", err)
}
if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].ViewerID != 2001 {
t.Fatalf("reaction list = %+v, want viewer 2001", list)
}
}
func TestGetStoryReactionsListRejectsUserPeer(t *testing.T) {
ctx := context.Background()
service := NewService(memory.NewStoryStore())
_, err := service.GetStoryReactionsList(ctx, 1001, domain.StoryReactionListRequest{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
StoryID: 1,
Limit: 10,
CanViewOwnerInteractions: true,
})
if !errors.Is(err, domain.ErrStoryPeerInvalid) {
t.Fatalf("user story reaction list err = %v, want ErrStoryPeerInvalid", err)
}
}
func TestStoryInteractionListsRejectMalformedOffsets(t *testing.T) {
ctx := context.Background()
service := NewService(nil)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{
Owner: owner,
StoryID: 1,
Offset: "bad",
Limit: 10,
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
t.Fatalf("bad views offset err = %v, want ErrStoryOffsetInvalid", err)
}
if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{
Owner: owner,
StoryID: 1,
Offset: strings.Repeat("9", domain.MaxStoryInteractionOffsetLength+1),
Limit: 10,
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
t.Fatalf("oversized views offset err = %v, want ErrStoryOffsetInvalid", err)
}
if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
StoryID: 1,
Offset: "1:1700000000:2001",
Limit: 10,
CanViewOwnerInteractions: true,
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
t.Fatalf("reaction group offset err = %v, want ErrStoryOffsetInvalid", err)
}
if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
StoryID: 1,
Offset: "1:1700000000:2001",
Limit: 10,
ForwardsFirst: true,
CanViewOwnerInteractions: true,
}); err != nil {
t.Fatalf("reaction forwards-first group offset err = %v, want nil", err)
}
}
func TestGetStoriesByIDDedupesAndRejectsEmptyIDs(t *testing.T) {
ctx := context.Background()
store := memory.NewStoryStore()
service := NewService(store)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 3,
Date: 100,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
list, err := service.GetStoriesByID(ctx, owner.ID, owner, []int{3, 3}, 200)
if err != nil {
t.Fatalf("get stories by duplicate ids: %v", err)
}
if len(list.Stories) != 1 || list.Stories[0].ID != 3 {
t.Fatalf("stories = %+v, want one deduped story", list.Stories)
}
if _, err := service.GetStoriesByID(ctx, owner.ID, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) {
t.Fatalf("empty get stories by id err = %v, want ErrStoryIDInvalid", err)
}
}
func TestIncrementViewsDedupeAndRejectsEmptyIDs(t *testing.T) {
ctx := context.Background()
store := memory.NewStoryStore()
service := NewService(store)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 7,
Date: 100,
ExpireDate: 1000,
Public: true,
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
created, err := service.IncrementViews(ctx, 2001, owner, []int{7, 7}, 200)
if err != nil {
t.Fatalf("increment views duplicate ids: %v", err)
}
if created != 1 {
t.Fatalf("created views = %d, want one deduped view", created)
}
if _, err := service.IncrementViews(ctx, 2001, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) {
t.Fatalf("empty increment views err = %v, want ErrStoryIDInvalid", err)
}
}
func TestStoryMutationsDedupeIDsBeforeStore(t *testing.T) {
ctx := context.Background()
service := NewService(nil)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
pinned, err := service.TogglePinned(ctx, owner.ID, owner, []int{3, 3, 4}, true, 100)
if err != nil {
t.Fatalf("toggle pinned: %v", err)
}
if got, want := pinned.IDs, []int{3, 4}; !intSlicesEqual(got, want) {
t.Fatalf("pinned ids = %v, want %v", got, want)
}
emptyPinned, err := service.TogglePinned(ctx, owner.ID, owner, nil, true, 100)
if err != nil {
t.Fatalf("empty toggle pinned: %v", err)
}
if len(emptyPinned.IDs) != 0 || len(emptyPinned.Stories) != 0 {
t.Fatalf("empty pinned = %+v, want no-op", emptyPinned)
}
deleted, err := service.DeleteStories(ctx, owner.ID, owner, []int{4, 3, 4}, 101)
if err != nil {
t.Fatalf("delete stories: %v", err)
}
if got, want := deleted.IDs, []int{4, 3}; !intSlicesEqual(got, want) {
t.Fatalf("deleted ids = %v, want %v", got, want)
}
if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{4, 3, 4}); err != nil {
t.Fatalf("toggle pinned to top deduped ids: %v", err)
}
if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{1, 2, 3, 4}); !errors.Is(err, domain.ErrStoryIDInvalid) {
t.Fatalf("oversized pinned-to-top err = %v, want ErrStoryIDInvalid", err)
}
}
func intSlicesEqual(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,230 @@
// Package themes 实现自定义云主题(account.createTheme/updateTheme/getTheme/
// saveTheme/installTheme)的业务编排:分配 id/access_hash/slug、校验创建者、维护每用户
// 已安装列表。文件上传(uploadTheme)由 rpc 层直接经 Files 服务完成,不经此服务。
package themes
import (
"context"
"crypto/rand"
"encoding/binary"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// 自定义主题 id/access_hash 用 crypto 随机 63 位,刻意区别于静态目录的保留区间
// (静态 emoji 主题用 92e16/93e16),避免 install/get-by-id 串到静态目录。
const defaultSlugPrefix = "t-"
// slug 字母表:小写 + 数字,URL 安全。
const slugAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// Service 提供自定义云主题业务。
type Service struct {
store store.ThemeStore
now func() time.Time
slugPrefix string
}
// Option 调整主题服务可选项。
type Option func(*Service)
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithSlugPrefix 覆盖自动分配 slug 的前缀。
func WithSlugPrefix(prefix string) Option {
return func(s *Service) { s.slugPrefix = prefix }
}
// NewService 创建主题服务。
func NewService(st store.ThemeStore, opts ...Option) *Service {
s := &Service{store: st, now: time.Now, slugPrefix: defaultSlugPrefix}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) ready() bool { return s != nil && s.store != nil }
// Create 创建一份新主题。spec.Slug 为空时自动分配;slug 冲突自动重试。
func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.Theme, error) {
if !s.ready() {
return domain.Theme{}, domain.ErrThemeInvalid
}
if spec.CreatorUserID == 0 {
return domain.Theme{}, domain.ErrThemeInvalid
}
userSlug := strings.TrimSpace(spec.Slug)
// 最多重试若干次(自动 slug/极小概率 id 冲突)。
for attempt := 0; attempt < 8; attempt++ {
t := domain.Theme{
ID: randInt63(),
AccessHash: randInt63(),
CreatorUserID: spec.CreatorUserID,
Title: spec.Title,
Emoticon: spec.Emoticon,
ForChat: spec.ForChat,
DocumentID: spec.DocumentID,
Settings: spec.Settings,
InstallsCount: 0,
CreatedAt: s.now().Unix(),
}
if userSlug != "" {
t.Slug = userSlug
} else {
t.Slug = s.slugPrefix + randSlug(12)
}
err := s.store.CreateTheme(ctx, t)
switch {
case err == nil:
return t.Clone(), nil
case err == domain.ErrThemeSlugTaken && userSlug != "":
// 用户指定的 slug 已被占用:不重试,直接报错。
return domain.Theme{}, domain.ErrThemeSlugTaken
case err == domain.ErrThemeSlugTaken:
continue // 自动 slug/id 冲突:换一组重试
default:
return domain.Theme{}, err
}
}
return domain.Theme{}, domain.ErrThemeSlugTaken
}
func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
if ref.ID != 0 {
return s.store.GetThemeByID(ctx, ref.ID)
}
if ref.Slug != "" {
return s.store.GetThemeBySlug(ctx, ref.Slug)
}
return domain.Theme{}, false, nil
}
// Get 按 id 或 slug 解析一份主题。
func (s *Service) Get(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
if !s.ready() {
return domain.Theme{}, false, domain.ErrThemeInvalid
}
return s.resolve(ctx, ref)
}
// Update 更新一份主题(仅创建者可改);部分字段。
func (s *Service) Update(ctx context.Context, userID int64, ref domain.ThemeRef, upd domain.ThemeUpdate) (domain.Theme, error) {
if !s.ready() {
return domain.Theme{}, domain.ErrThemeInvalid
}
t, ok, err := s.resolve(ctx, ref)
if err != nil {
return domain.Theme{}, err
}
if !ok {
return domain.Theme{}, domain.ErrThemeNotFound
}
if !t.IsCreator(userID) {
return domain.Theme{}, domain.ErrThemeInvalid
}
if upd.Slug != nil {
t.Slug = strings.TrimSpace(*upd.Slug)
}
if upd.Title != nil {
t.Title = *upd.Title
}
if upd.DocumentID != nil {
t.DocumentID = *upd.DocumentID
}
if upd.Settings != nil {
t.Settings = *upd.Settings
}
if err := s.store.UpdateTheme(ctx, t); err != nil {
return domain.Theme{}, err
}
return t.Clone(), nil
}
// Save 把主题加入用户已存列表(saveTheme,unsave=false)。
func (s *Service) Save(ctx context.Context, userID int64, ref domain.ThemeRef) error {
return s.membership(ctx, userID, ref, false, false)
}
// Unsave 从用户列表移除(saveTheme,unsave=true)。
func (s *Service) Unsave(ctx context.Context, userID int64, ref domain.ThemeRef) error {
if !s.ready() {
return domain.ErrThemeInvalid
}
t, ok, err := s.resolve(ctx, ref)
if err != nil {
return err
}
if !ok {
return domain.ErrThemeInvalid
}
return s.store.RemoveInstalled(ctx, userID, t.ID)
}
// Install 应用主题:计数 +1 并加入用户已安装列表。
func (s *Service) Install(ctx context.Context, userID int64, ref domain.ThemeRef, dark bool) error {
return s.membership(ctx, userID, ref, true, dark)
}
func (s *Service) membership(ctx context.Context, userID int64, ref domain.ThemeRef, increment, dark bool) error {
if !s.ready() {
return domain.ErrThemeInvalid
}
t, ok, err := s.resolve(ctx, ref)
if err != nil {
return err
}
if !ok {
return domain.ErrThemeInvalid
}
if increment {
if err := s.store.IncrementInstalls(ctx, t.ID); err != nil {
return err
}
}
return s.store.SetInstalled(ctx, userID, t.ID, dark)
}
// ListInstalled 返回用户已安装/已存主题。
func (s *Service) ListInstalled(ctx context.Context, userID int64) ([]domain.Theme, error) {
if !s.ready() {
return nil, domain.ErrThemeInvalid
}
return s.store.ListInstalledByUser(ctx, userID)
}
// ListForUser 返回用户「创建 ∪ 安装」的主题,供 getThemes 跨设备同步。
func (s *Service) ListForUser(ctx context.Context, userID int64) ([]domain.Theme, error) {
if !s.ready() {
return nil, domain.ErrThemeInvalid
}
return s.store.ListThemesForUser(ctx, userID)
}
// randInt63 返回一个 crypto 随机的正 63 位整数。
func randInt63() int64 {
var b [8]byte
_, _ = rand.Read(b[:])
return int64(binary.BigEndian.Uint64(b[:]) & 0x7fffffffffffffff)
}
// randSlug 返回 n 个字符的随机 slug 片段。
func randSlug(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
out := make([]byte, n)
for i := range out {
out[i] = slugAlphabet[int(b[i])%len(slugAlphabet)]
}
return string(out)
}

Some files were not shown because too many files have changed in this diff Show more