chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
506
internal/app/account/business.go
Normal file
506
internal/app/account/business.go
Normal 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
|
||||
}
|
||||
158
internal/app/account/business_test.go
Normal file
158
internal/app/account/business_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
116
internal/app/account/login_email_test.go
Normal file
116
internal/app/account/login_email_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:]...)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue