Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
3
internal/app/contacts/doc.go
Normal file
3
internal/app/contacts/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package contacts 是联系人应用服务:联系人、拉黑、搜索。
|
||||
// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。
|
||||
package contacts
|
||||
266
internal/app/contacts/service.go
Normal file
266
internal/app/contacts/service.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContactIDInvalid = errors.New("contact id invalid")
|
||||
ErrContactNameEmpty = errors.New("contact name empty")
|
||||
)
|
||||
|
||||
const maxSearchLimit = 50
|
||||
|
||||
// Service 提供通讯录查询。
|
||||
type Service struct {
|
||||
contacts store.ContactStore
|
||||
users store.UserStore
|
||||
}
|
||||
|
||||
// NewService 创建 contacts 服务。
|
||||
func NewService(contacts store.ContactStore, users ...store.UserStore) *Service {
|
||||
s := &Service{contacts: contacts}
|
||||
if len(users) > 0 {
|
||||
s.users = users[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return domain.ContactList{}, false, nil
|
||||
}
|
||||
list, err := s.contacts.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
if s.users != nil && len(list.Contacts) > 0 {
|
||||
if err := s.attachCurrentLastSeen(ctx, &list); err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
}
|
||||
if hash != 0 && hash == list.Hash {
|
||||
return list, true, nil
|
||||
}
|
||||
return list, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error {
|
||||
ids := make([]int64, 0, len(list.Contacts))
|
||||
seen := make(map[int64]struct{}, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
id := contact.User.ID
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := make(map[int64]domain.User, len(users))
|
||||
for _, u := range users {
|
||||
current[u.ID] = u
|
||||
}
|
||||
for i := range list.Contacts {
|
||||
if u, ok := current[list.Contacts[i].User.ID]; ok {
|
||||
list.Contacts[i].User.LastSeenAt = u.LastSeenAt
|
||||
list.Contacts[i].User.Status = u.Status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
if s.users != nil {
|
||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.Phone == "" {
|
||||
input.Phone = target.Phone
|
||||
}
|
||||
}
|
||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) {
|
||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 {
|
||||
return domain.ImportContactsResult{}, nil
|
||||
}
|
||||
out := domain.ImportContactsResult{
|
||||
Imported: make([]domain.ImportedContact, 0, len(inputs)),
|
||||
Contacts: make([]domain.Contact, 0, len(inputs)),
|
||||
}
|
||||
normalized := make([]domain.ContactInput, 0, len(inputs))
|
||||
phones := make([]string, 0, len(inputs))
|
||||
seenPhones := make(map[string]struct{}, len(inputs))
|
||||
for _, input := range inputs {
|
||||
phone := normalizePhone(input.Phone)
|
||||
if phone == "" {
|
||||
continue
|
||||
}
|
||||
input.Phone = phone
|
||||
normalized = append(normalized, input)
|
||||
if _, ok := seenPhones[phone]; ok {
|
||||
continue
|
||||
}
|
||||
seenPhones[phone] = struct{}{}
|
||||
phones = append(phones, phone)
|
||||
}
|
||||
if len(phones) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
targets, err := s.users.ByPhones(ctx, phones)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
byPhone := make(map[string]domain.User, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Phone != "" {
|
||||
byPhone[target.Phone] = target
|
||||
}
|
||||
}
|
||||
upsertsByTarget := make(map[int64]domain.ContactInput, len(targets))
|
||||
order := make([]int64, 0, len(targets))
|
||||
seenTargets := map[int64]struct{}{}
|
||||
for _, input := range normalized {
|
||||
target, found := byPhone[input.Phone]
|
||||
if !found || target.ID == userID || target.ID == 0 {
|
||||
continue
|
||||
}
|
||||
input.ContactUserID = target.ID
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
input.FirstName = target.FirstName
|
||||
input.LastName = target.LastName
|
||||
}
|
||||
if _, ok := seenTargets[target.ID]; !ok {
|
||||
seenTargets[target.ID] = struct{}{}
|
||||
order = append(order, target.ID)
|
||||
}
|
||||
out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID})
|
||||
upsertsByTarget[target.ID] = input
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
upserts := make([]domain.ContactInput, 0, len(order))
|
||||
for _, targetID := range order {
|
||||
upserts = append(upserts, upsertsByTarget[targetID])
|
||||
}
|
||||
contacts, err := s.contacts.UpsertMany(ctx, userID, upserts)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
out.Contacts = append(out.Contacts, contacts...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) {
|
||||
if s == nil || s.users == nil || userID == 0 {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
query = strings.TrimPrefix(query, "@")
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > maxSearchLimit {
|
||||
limit = maxSearchLimit
|
||||
}
|
||||
return s.users.Search(ctx, userID, query, normalizePhone(query), limit)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.contacts.Delete(ctx, userID, contactUserIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
|
||||
return domain.PeerSettings{}, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.PeerSettings{}, err
|
||||
}
|
||||
return domain.PeerSettings{
|
||||
AddContact: !found,
|
||||
BlockContact: !found,
|
||||
ShareContact: found,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||
list, notModified, err := s.GetContacts(ctx, userID, hash)
|
||||
if err != nil || notModified {
|
||||
return nil, notModified, err
|
||||
}
|
||||
ids := make([]int, 0, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
ids = append(ids, int(contact.User.ID))
|
||||
}
|
||||
return ids, false, nil
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
if !utf8.ValidString(phone) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
44
internal/app/contacts/service_test.go
Normal file
44
internal/app/contacts/service_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users)
|
||||
|
||||
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{
|
||||
{ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"},
|
||||
{ClientID: 12, Phone: "15551234567", FirstName: "Alice Final"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts: %v", err)
|
||||
}
|
||||
if len(res.Imported) != 2 {
|
||||
t.Fatalf("imported = %d, want 2", len(res.Imported))
|
||||
}
|
||||
if res.Imported[0].UserID != target.ID || res.Imported[1].UserID != target.ID {
|
||||
t.Fatalf("imported user ids = %+v, want target %d", res.Imported, target.ID)
|
||||
}
|
||||
if len(res.Contacts) != 1 {
|
||||
t.Fatalf("contacts = %d, want 1 deduped upsert", len(res.Contacts))
|
||||
}
|
||||
if res.Contacts[0].FirstName != "Alice Final" {
|
||||
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue