Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

View file

@ -0,0 +1,3 @@
// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。
// 第一阶段先支持 users.getUsers 返回自身。
package users

View file

@ -0,0 +1,313 @@
package users
import (
"context"
"errors"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
var ErrNotAuthorized = errors.New("not authorized")
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User
type ProfilePhotoProvider interface {
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
}
// Service 提供用户查询。
type Service struct {
users store.UserStore
photos ProfilePhotoProvider
}
// Option 调整用户服务可选依赖。
type Option func(*Service)
// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。
func WithPhotoProvider(p ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
}
const (
minUsernameLen = 5
maxUsernameLen = 32
maxProfileNameRunes = 64
maxProfileAboutRunes = 70
maxBatchUsers = 1000
)
// NewService 创建用户服务。
func NewService(users store.UserStore, opts ...Option) *Service {
s := &Service{users: users}
for _, opt := range opts {
opt(s)
}
return s
}
// loadSelf 加载当前用户但不富化头像(供内部校验路径使用,避免无谓的头像查询)。
func (s *Service) loadSelf(ctx context.Context, userID int64) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, ErrNotAuthorized
}
return u, nil
}
// Self 返回当前登录的用户(带头像)。未登录返回 ErrNotAuthorized。
func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) {
u, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
return s.enrichOne(ctx, u), nil
}
// ByID 返回指定用户。调用方必须已登录access_hash 校验在 RPC 边界完成。
func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) {
if currentUserID == 0 {
return domain.User{}, false, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, false, err
}
if !found {
return u, false, nil
}
return s.enrichOne(ctx, u), true, nil
}
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {
return nil, ErrNotAuthorized
}
if len(userIDs) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, id := range userIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
if len(ids) >= maxBatchUsers {
break
}
}
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return nil, err
}
return s.enrich(ctx, users), nil
}
// enrich 批量把当前头像富化到用户列表best-effort失败不影响用户查询
func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User {
if s.photos == nil || len(users) == 0 {
return users
}
ids := make([]int64, 0, len(users))
for _, u := range users {
if u.ID != 0 {
ids = append(ids, u.ID)
}
}
refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
if err != nil {
return users
}
for i := range users {
if ref, ok := refs[users[i].ID]; ok {
users[i].PhotoID = ref.PhotoID
users[i].PhotoDCID = ref.DCID
users[i].PhotoStripped = ref.Stripped
}
}
return users
}
func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User {
enriched := s.enrich(ctx, []domain.User{u})
return enriched[0]
}
// CheckUsername 校验当前用户是否可以占用 username。
func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return false, err
}
username = normalizeUsername(username)
if !validUsername(username) {
return false, domain.ErrUsernameInvalid
}
u, found, err := s.users.ByUsername(ctx, username)
if err != nil {
return false, err
}
return !found || u.ID == self.ID, nil
}
// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
username = normalizeUsername(username)
if username != "" {
if !validUsername(username) {
return domain.User{}, domain.ErrUsernameInvalid
}
u, found, err := s.users.ByUsername(ctx, username)
if err != nil {
return domain.User{}, err
}
if found && u.ID != self.ID {
return domain.User{}, domain.ErrUsernameOccupied
}
}
if self.Username == username {
return self, nil
}
u, err := s.users.UpdateUsername(ctx, self.ID, username)
if err != nil {
return domain.User{}, err
}
return u, nil
}
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
firstName := self.FirstName
lastName := self.LastName
about := self.About
if update.HasFirstName {
firstName = strings.TrimSpace(update.FirstName)
}
if update.HasLastName {
lastName = strings.TrimSpace(update.LastName)
}
if update.HasAbout {
about = strings.TrimSpace(update.About)
}
if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes {
return domain.User{}, domain.ErrFirstNameInvalid
}
if utf8.RuneCountInString(about) > maxProfileAboutRunes {
return domain.User{}, domain.ErrAboutTooLong
}
if firstName == self.FirstName && lastName == self.LastName && about == self.About {
return self, nil
}
return s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about)
}
// UpdateLastSeen records the latest visible account activity time.
func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
if userID == 0 {
return ErrNotAuthorized
}
if lastSeenAt <= 0 {
return nil
}
return s.users.UpdateLastSeen(ctx, userID, lastSeenAt)
}
// ResolveUsername 解析 username 到用户;调用方必须已登录。
func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) {
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
return domain.User{}, false, err
}
username = normalizeUsername(username)
if !validUsername(username) {
return domain.User{}, false, domain.ErrUsernameInvalid
}
u, found, err := s.users.ByUsername(ctx, username)
if err != nil || !found {
return u, found, err
}
return s.enrichOne(ctx, u), true, nil
}
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
return domain.User{}, false, err
}
phone = normalizePhone(phone)
if phone == "" {
return domain.User{}, false, domain.ErrPhoneNotOccupied
}
u, found, err := s.users.ByPhone(ctx, phone)
if err != nil || !found {
return u, found, err
}
return s.enrichOne(ctx, u), true, nil
}
func normalizeUsername(username string) string {
username = strings.TrimSpace(username)
username = strings.TrimPrefix(username, "@")
return strings.TrimSpace(username)
}
func validUsername(username string) bool {
if len(username) < minUsernameLen || len(username) > maxUsernameLen {
return false
}
for i := 0; i < len(username); i++ {
c := username[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
if i == 0 {
return false
}
case c == '_':
if i == 0 {
return false
}
default:
return false
}
}
return true
}
func normalizePhone(phone string) string {
phone = strings.TrimSpace(phone)
if phone == "" {
return ""
}
var b strings.Builder
b.Grow(len(phone))
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}

View file

@ -0,0 +1,130 @@
package users
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestServiceUsernameLifecycle(t *testing.T) {
ctx := context.Background()
store := memory.NewUserStore()
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"})
if err != nil {
t.Fatalf("create other: %v", err)
}
svc := NewService(store)
if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err)
}
if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok {
t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err)
}
if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok {
t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err)
}
updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name")
if err != nil {
t.Fatalf("UpdateUsername: %v", err)
}
if updated.Username != "Owner_Name" {
t.Fatalf("updated username = %q, want Owner_Name", updated.Username)
}
resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name")
if err != nil || !found || resolved.ID != owner.ID {
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
}
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
}
phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002")
if err != nil || !found || phoneUser.ID != other.ID {
t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err)
}
cleared, err := svc.UpdateUsername(ctx, owner.ID, "")
if err != nil {
t.Fatalf("clear username: %v", err)
}
if cleared.Username != "" {
t.Fatalf("cleared username = %q, want empty", cleared.Username)
}
}
func TestServiceUpdateProfile(t *testing.T) {
ctx := context.Background()
store := memory.NewUserStore()
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
svc := NewService(store)
updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{
FirstName: " New ",
HasFirstName: true,
LastName: "Name",
HasLastName: true,
About: "bio",
HasAbout: true,
})
if err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" {
t.Fatalf("updated profile = %+v, want trimmed names and about", updated)
}
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) {
t.Fatalf("empty first name err = %v, want first name invalid", err)
}
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) {
t.Fatalf("long about err = %v, want about too long", err)
}
}
func TestServiceByIDDoesNotReloadSelf(t *testing.T) {
ctx := context.Background()
base := memory.NewUserStore()
owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
store := &countingUserStore{UserStore: base}
svc := NewService(store)
got, found, err := svc.ByID(ctx, owner.ID, target.ID)
if err != nil || !found || got.ID != target.ID {
t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err)
}
if store.byIDCalls != 1 {
t.Fatalf("store ByID calls = %d, want 1 target lookup only", store.byIDCalls)
}
if store.lastByID != target.ID {
t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID)
}
}
type countingUserStore struct {
*memory.UserStore
byIDCalls int
lastByID int64
}
func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) {
s.byIDCalls++
s.lastByID = id
return s.UserStore.ByID(ctx, id)
}