owpengram-server/internal/app/users/service.go
Astra d83034e8dc admin: allow assigning a reserved username to a user via the admin console
Add users.Service.UpdateUsernameAdmin, which skips the
config.ReservedUsernames block that self-service UpdateUsername enforces.
Operators need to be able to hand a reserved word to a specific account
even though regular users can't claim it themselves.
2026-09-15 12:04:26 +01:00

1013 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package users
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"telesrv/internal/app/userprojection"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
var (
ErrNotAuthorized = errors.New("not authorized")
ErrSystemUserImmutable = errors.New("system user identity is immutable")
ErrBatchUsersLimit = errors.New("batch users limit exceeded")
ErrBatchViewerCells = errors.New("batch viewer projection cell limit exceeded")
ErrBatchUserMissing = errors.New("batch user projection source is incomplete")
ErrLastSeenBatchUnsupported = errors.New("last seen batch store unsupported")
)
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User
type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
// Service 提供用户查询。
type Service struct {
users store.UserStore
cache store.UserCache
contacts store.ContactStore
photos ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
freezes userprojection.AccountFreezeProvider
projector *userprojection.Projector
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
// while true, ResolveUsername never resolves @marksbot
// (domain.VerifierBotUserID), so a client cannot discover it by username.
hideThirdPartyVerification bool
// reserved blocks UpdateUsername (self-service only) from claiming a
// config.ReservedUsernames entry -- see domain.ReservedUsernameSet.
reserved domain.ReservedUsernameSet
}
type usernameAvailabilityStore interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
}
type moderationFlagAudienceStore interface {
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
}
// Option 调整用户服务可选依赖。
type Option func(*Service)
// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。
func WithPhotoProvider(p ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
}
// WithBaseUserCache injects a viewer-independent user base cache.
func WithBaseUserCache(c store.UserCache) Option {
return func(s *Service) { s.cache = c }
}
// WithContactStore enables viewer-specific contact name/phone projection.
func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
// WithPrivacyEvaluator enables viewer-specific privacy projection.
func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
return func(s *Service) { s.privacy = p }
}
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
return func(s *Service) { s.freezes = p }
}
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
// while true, ResolveUsername treats @marksbot as not found.
func WithHideThirdPartyVerification(hidden bool) Option {
return func(s *Service) { s.hideThirdPartyVerification = hidden }
}
// WithReservedUsernames mirrors config.ReservedUsernames: UpdateUsername
// (self-service only) refuses to set any of these.
func WithReservedUsernames(names []string) Option {
return func(s *Service) { s.reserved = domain.NewReservedUsernameSet(names) }
}
const (
minUsernameLen = 5
maxUsernameLen = 32
maxProfileNameRunes = 64
// bio 长度双档,对齐 appConfig about_length_limit_default=70 /
// about_length_limit_premium=140客户端按 self premium flag 选档,
// 服务端档位必须 ≥ 客户端宣告档位。
maxProfileAboutRunes = 70
maxProfileAboutRunesPremium = 140
maxBatchUsers = 1000
// A dense fan-out materializes one complete domain.User per viewer/owner
// cell in both the result and the batch cache. Bound the retained graph.
maxBatchViewerProjectionCells = 131072
)
// NewService 创建用户服务。
func NewService(users store.UserStore, opts ...Option) *Service {
s := &Service{users: users}
for _, opt := range opts {
opt(s)
}
s.projector = userprojection.New(
userprojection.WithContactStore(s.contacts),
userprojection.WithPhotoProvider(s.photos),
userprojection.WithPrivacyEvaluator(s.privacy),
userprojection.WithAccountFreezeProvider(s.freezes),
)
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.loadBaseUserByID(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.projectOne(ctx, userID, u)
}
// 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.loadBaseUserByID(ctx, userID)
if err != nil {
return domain.User{}, false, err
}
if !found {
return u, false, nil
}
u, err = s.projectOne(ctx, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
// AdminUser 返回 viewer 无关的账号基础事实,供管理用例做 dry-run 与审计。
func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, bool, error) {
if userID == 0 {
return domain.User{}, false, nil
}
return s.loadBaseUserByID(ctx, userID)
}
// BotStatus returns only the immutable viewer-independent bot fact. Presence
// classification must not pay for contact/privacy/photo projection.
func (s *Service) BotStatus(ctx context.Context, userID int64) (bool, bool, error) {
if userID == 0 {
return false, false, nil
}
u, found, err := s.loadBaseUserByID(ctx, userID)
if err != nil || !found {
return false, found, err
}
return u.Bot, true, nil
}
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
// shared base-user read model. Privacy uses this as a batched cold loader behind
// its bounded process cache; no viewer projection is performed, avoiding a
// privacy -> users -> privacy recursion.
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// BaseUsersByIDs returns viewer-independent identities for bounded write/read
// validation paths without constructing full viewer projections.
func (s *Service) BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// 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
}
if len(ids) >= maxBatchUsers {
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
users, err := s.loadBaseUsersByIDs(ctx, ids)
if err != nil {
return nil, err
}
return s.projectUsers(ctx, currentUserID, users)
}
// ByIDsForViewers 跨多个 viewer 批量投影同一组 userfan-out 模板化base user 只加载一次,
// 隐私/改名/头像投影经 userprojection.ForViewers 收敛成批量查询。返回 map[viewerID][]User
// 每个切片与 ByIDs(viewer, ids) 字节等价,包含 viewer-specific personal photo overlay。
// 供 channel fan-out 预热每 viewer 投影,
// 把 per-recipient 的 ByIDs(=ForViewer) 折叠成一次跨 viewer 投影。不做 ByIDs 的单 caller 鉴权
// viewer 是 fan-out 收件人集合,非 RPC 调用方)。
func (s *Service) ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error) {
if len(viewerUserIDs) == 0 || len(userIDs) == 0 {
return map[int64][]domain.User{}, nil
}
ids := uniqueUserIDs(userIDs, 0)
if len(ids) > maxBatchUsers {
return nil, fmt.Errorf("%w: got %d unique owners, maximum %d", ErrBatchUsersLimit, len(ids), maxBatchUsers)
}
viewers := uniqueUserIDs(viewerUserIDs, 0)
if !batchViewerProjectionCellsAllowed(len(viewers), len(ids)) {
return nil, fmt.Errorf("%w: got %d viewers x %d owners, maximum %d cells", ErrBatchViewerCells, len(viewers), len(ids), maxBatchViewerProjectionCells)
}
base, err := s.loadBaseUsersByIDs(ctx, ids)
if err != nil {
return nil, err
}
base, err = requireBatchBaseUsers(ids, base)
if err != nil {
return nil, err
}
// projector 为 nil 时 ForViewers 返回各 viewer 的原始 base 副本(与 projectUsers 的 nil 分支一致)。
return s.projector.ForViewers(ctx, viewers, base)
}
func batchViewerProjectionCellsAllowed(viewers, owners int) bool {
if viewers <= 0 || owners <= 0 {
return true
}
// Division avoids overflow from viewers*owners on hostile inputs.
return viewers <= maxBatchViewerProjectionCells/owners
}
// requireBatchBaseUsers turns the fan-out projection API into a complete
// envelope contract. Deleted users remain durable tombstones and therefore
// still appear in base; a truly missing referenced user must fail closed rather
// than produce a message whose sender cannot be resolved. System users are
// protocol-local constants and do not require a backing users row.
func requireBatchBaseUsers(ids []int64, base []domain.User) ([]domain.User, error) {
byID := make(map[int64]domain.User, len(base))
for _, user := range base {
if user.ID != 0 {
byID[user.ID] = user
}
}
out := make([]domain.User, 0, len(ids))
for _, id := range ids {
if user, ok := byID[id]; ok {
out = append(out, user)
continue
}
if system, ok := domain.SystemUserByID(id); ok {
out = append(out, system)
continue
}
return nil, fmt.Errorf("%w: user_id=%d", ErrBatchUserMissing, id)
}
return out, nil
}
// 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
}
return s.checkUsernameAvailable(ctx, self.ID, username)
}
func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, username string) (bool, error) {
if checker, ok := s.users.(usernameAvailabilityStore); ok {
return checker.CheckUsername(ctx, selfID, username)
}
u, found, err := s.users.ByUsername(ctx, username)
if err != nil {
return false, err
}
return !found || u.ID == selfID, nil
}
// UpdateUsername 修改当前用户的主 usernameself-service。空字符串表示删除 username。
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
return s.updateUsername(ctx, userID, username, true)
}
// UpdateUsernameAdmin sets a user's username on the operator's behalf (via
// the admin console). Unlike UpdateUsername, it does not consult
// config.ReservedUsernames -- an operator who deliberately reserved a word
// still needs to be able to hand it to a specific account. Validity and
// availability (no collision with another account) are still enforced.
func (s *Service) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) {
return s.updateUsername(ctx, userID, username, false)
}
func (s *Service) updateUsername(ctx context.Context, userID int64, username string, enforceReserved bool) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
username = normalizeUsername(username)
// Re-submitting the username the account already has is a no-op, not a
// claim -- checked before any validation (including the reserved-word
// list) so a name that was fine to keep before this feature existed
// (or before it was added to config.ReservedUsernames) never gets
// rejected just because the client re-sent an unchanged value.
if self.Username == username {
return s.projectOne(ctx, self.ID, self)
}
if username != "" {
if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) {
return domain.User{}, domain.ErrUsernameInvalid
}
ok, err := s.checkUsernameAvailable(ctx, self.ID, username)
if err != nil {
return domain.User{}, err
}
if !ok {
return domain.User{}, domain.ErrUsernameOccupied
}
}
u, err := s.users.UpdateUsername(ctx, self.ID, username)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// 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
}
aboutLimit := maxProfileAboutRunes
if self.PremiumActiveAt(time.Now().Unix()) {
aboutLimit = maxProfileAboutRunesPremium
}
if utf8.RuneCountInString(about) > aboutLimit {
return domain.User{}, domain.ErrAboutTooLong
}
if firstName == self.FirstName && lastName == self.LastName && about == self.About {
return s.projectOne(ctx, self.ID, self)
}
u, err := s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// SetPhone force-sets the authoritative phone for the trusted admin path. It
// remains a non-PTS profile mutation because updateUserPhone and updateUser
// carry no pts/pts_count in every admitted exact layer.
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
if self.Bot || domain.IsSystemUserID(self.ID) {
return domain.User{}, domain.ErrPhoneChangeForbidden
}
phone = domain.NormalizePhone(strings.TrimSpace(phone))
if !domain.ValidPhone(phone) {
return domain.User{}, domain.ErrPhoneNumberInvalid
}
if phone == self.Phone {
return s.projectOne(ctx, self.ID, self)
}
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
return domain.User{}, err
} else if found && existing.ID != self.ID {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// 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
}
if err := s.users.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil {
return err
}
s.dropCachedUsers(ctx, userID)
return nil
}
// UpdateLastSeenBatch is the production lifecycle-presence write boundary. It
// requires a real batch-capable store: silently looping over UpdateLastSeen
// would recreate the exact per-account transaction fan-out this API exists to
// remove. The cache delete is part of batch completion; callers may retry the
// whole idempotent batch when Redis is temporarily unavailable.
func (s *Service) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error {
batch, ok := s.users.(store.UserLastSeenBatchStore)
if !ok {
return ErrLastSeenBatchUnsupported
}
latest := make(map[int64]int, len(updates))
for _, update := range updates {
if update.UserID == 0 || update.LastSeenAt <= 0 {
continue
}
if current := latest[update.UserID]; update.LastSeenAt > current {
latest[update.UserID] = update.LastSeenAt
}
}
if len(latest) == 0 {
return nil
}
merged := make([]store.UserLastSeenUpdate, 0, len(latest))
userIDs := make([]int64, 0, len(latest))
for userID, lastSeenAt := range latest {
merged = append(merged, store.UserLastSeenUpdate{UserID: userID, LastSeenAt: lastSeenAt})
userIDs = append(userIDs, userID)
}
if err := batch.UpdateLastSeenBatch(ctx, merged); err != nil {
return err
}
if s.cache != nil {
if err := s.cache.Delete(ctx, userIDs); err != nil {
return fmt.Errorf("invalidate last seen batch user cache: %w", err)
}
}
return nil
}
// PremiumActive 报告用户当前是否有效会员。走基础用户缓存路径、不做 viewer
// 投影供限额双档判断pin 上限、reaction 上限、bio 长度等)低成本调用。
func (s *Service) PremiumActive(ctx context.Context, userID int64) bool {
if s == nil || userID == 0 {
return false
}
u, found, err := s.loadBaseUserByID(ctx, userID)
if err != nil || !found {
return false
}
return u.PremiumActiveAt(time.Now().Unix())
}
// GrantPremium 授予/续期会员:未过期则在现有到期时间上累加 months 个月,
// 已过期或首次则从当前时刻起算对齐官方续期语义。months<=0 清除会员。
// bot 永不可成为会员。
func (s *Service) GrantPremium(ctx context.Context, userID int64, months int) (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{}, domain.ErrUserNotFound
}
if u.Bot {
return domain.User{}, domain.ErrPremiumBotUnsupported
}
until := 0
if months > 0 {
now := time.Now()
base := now
if u.PremiumUntil > 0 && int64(u.PremiumUntil) > now.Unix() {
base = time.Unix(int64(u.PremiumUntil), 0)
}
until = int(base.AddDate(0, months, 0).Unix())
}
updated, err := s.users.SetPremiumUntil(ctx, userID, until)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return s.projectOne(ctx, userID, updated)
}
// SetVerified 设置/取消用户认证标记。认证是账号基础事实,所有 user 投影统一消费该字段。
func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
if domain.IsSystemUserID(userID) && !verified {
return domain.User{}, ErrSystemUserImmutable
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Verified == verified {
return s.projectOne(ctx, userID, u)
}
updated, err := s.users.SetVerified(ctx, userID, verified)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return s.projectOne(ctx, userID, updated)
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径。scam/fake
// 是账号基础事实,所有 user 投影统一消费;写后刷新基础缓存以便投影即时可见。
func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Scam == scam && u.Fake == fake {
return u, nil
}
updated, err := s.users.SetScamFake(ctx, userID, scam, fake)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// ModerationFlagAudience returns the bounded set of existing viewers that may
// need an immediate updateUser after SCAM/FAKE changes. This is an online
// accelerator only: it does not allocate PTS or create durable update events.
func (s *Service) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
if userID == 0 {
return nil, ErrNotAuthorized
}
if limit <= 0 || limit > 4096 {
limit = 4096
}
audience, ok := s.users.(moderationFlagAudienceStore)
if !ok {
return []int64{userID}, nil
}
return audience.ModerationFlagAudience(ctx, userID, limit)
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (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{}, domain.ErrUserNotFound
}
if u.Support == support {
return u, nil
}
updated, err := s.users.SetSupport(ctx, userID, support)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// SweepExpiredPremium 清理到期会员store 把过期行清 NULL并失效用户缓存
// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发
// 正确性由读取路径即时派生保证,这里只做收尾与通知。
func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) {
users, err := s.users.SweepExpiredPremium(ctx, now, limit)
if err != nil || len(users) == 0 {
return users, err
}
ids := make([]int64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
s.dropCachedUsers(ctx, ids...)
return users, nil
}
// UpdateEmojiStatus 更新当前用户 emoji statuspremium 专属;零值清除)。
// 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。
func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
if err != nil {
return domain.User{}, err
}
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// UpdateEmojiStatusWithEvent uses the store's aggregate transaction when it
// is available. The bool reports whether the returned event was durably
// appended with dispatch; lightweight memory/test wiring falls back to the
// ordinary state write and lets the RPC's Updates service append the event.
func (s *Service) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) {
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
if err != nil {
return domain.User{}, domain.UpdateEvent{}, false, err
}
writer, ok := s.users.(store.UserEmojiStatusEventStore)
if !ok {
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
if err == nil {
s.refreshCachedUsers(ctx, u)
}
return u, domain.UpdateEvent{}, false, err
}
event := domain.UpdateEvent{
Type: domain.UpdateEventUserEmojiStatus,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: self.ID},
EmojiStatus: status,
Date: date,
PtsCount: 1,
}
u, event, err := writer.UpdateEmojiStatusWithEvent(ctx, self.ID, status, event, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.User{}, domain.UpdateEvent{}, false, err
}
s.refreshCachedUsers(ctx, u)
return u, event, true, nil
}
func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !status.Valid() {
return domain.User{}, domain.ErrEmojiStatusCollectibleInvalid
}
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
return domain.User{}, domain.ErrPremiumRequired
}
return self, nil
}
// UpdateBirthday 设置/清除用户生日account.updateBirthday。零值 Birthday 表示清除。
func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
if birthday.IsSet() {
if !domain.ValidBirthday(birthday) {
return domain.User{}, domain.ErrBirthdayInvalid
}
} else {
birthday = domain.Birthday{} // 归一化为清除
}
u, err := s.users.UpdateBirthday(ctx, self.ID, birthday)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// UpdatePersonalChannel 设置/清除资料页个人频道account.updatePersonalChannel
// channelID=0 表示清除。频道存在性与「调用者是其成员」由 RPC 层在调用前校验。
func (s *Service) UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
u, err := s.users.UpdatePersonalChannel(ctx, self.ID, channelID)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// UpdateColor updates the user's message accent or profile background color.
func (s *Service) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
u, err := s.users.UpdateColor(ctx, self.ID, forProfile, color)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// 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)
_, reservedSystemUsername := domain.SystemUserByUsername(username)
// Resolution covers both the editable username slot (5..32) and
// Fragment-style collectible usernames (4..32). Keep the stricter
// validUsername check on create/update paths; only lookup accepts the
// collectible lower bound. Built-in system accounts (e.g. @gif, 3
// characters) are exempted from the floor itself -- they're fixed,
// server-controlled handles, not user input -- but still resolve through
// the normal DB-backed path below, so caching/projection/hidden-bot
// handling stay exactly as for any other account.
if !reservedSystemUsername && !domain.ValidCollectibleUsername(username) {
return domain.User{}, false, domain.ErrUsernameInvalid
}
u, found, err := s.users.ByUsername(ctx, username)
if err != nil || !found {
return u, found, err
}
if s.hideThirdPartyVerification && u.ID == domain.VerifierBotUserID {
return domain.User{}, false, nil
}
s.putCachedUsers(ctx, u)
u, err = s.projectOne(ctx, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
// ResolvePhone resolves a phone number only when the target's AddedByPhone
// privacy allows the current viewer. The evaluator is backed by owner-level
// privacy/contact snapshots in production, so this adds no per-rule SQL query.
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 = domain.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
}
s.putCachedUsers(ctx, u)
if s.privacy != nil && u.ID != currentUserID {
allowed := false
var err error
if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok {
visibility, batchErr := batch.CanSeeBatch(
ctx,
[]int64{u.ID},
currentUserID,
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
)
err = batchErr
allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone]
} else {
allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone)
}
if err != nil {
return domain.User{}, false, err
}
if !allowed {
return domain.User{}, false, domain.ErrPhoneNotOccupied
}
}
u, err = s.projectOne(ctx, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
func (s *Service) projectUsers(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
if s == nil || s.projector == nil {
return users, nil
}
return s.projector.ForViewer(ctx, viewerUserID, users)
}
func (s *Service) loadBaseUserByID(ctx context.Context, userID int64) (domain.User, bool, error) {
users, err := s.loadBaseUsersByIDs(ctx, []int64{userID})
if err != nil {
return domain.User{}, false, err
}
if len(users) == 0 {
return domain.User{}, false, nil
}
return users[0], true, nil
}
func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
ids := uniqueUserIDs(userIDs, maxBatchUsers+1)
if len(ids) > maxBatchUsers {
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
}
if len(ids) == 0 {
return nil, nil
}
loaded := make(map[int64]domain.User, len(ids))
misses := append([]int64(nil), ids...)
if s.cache != nil {
if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 {
for id, u := range cached {
if u.ID != 0 && u.EmojiStatusCollectible.Empty() {
loaded[id] = u
}
}
misses = make([]int64, 0, len(ids))
for _, id := range ids {
if _, ok := loaded[id]; !ok {
misses = append(misses, id)
}
}
}
}
if len(misses) > 0 {
users, err := s.users.ByIDs(ctx, misses)
if err != nil {
return nil, err
}
for _, u := range users {
if u.ID != 0 {
loaded[u.ID] = u
}
}
s.putCachedUsers(ctx, users...)
}
out := make([]domain.User, 0, len(ids))
for _, id := range ids {
if u, ok := loaded[id]; ok {
out = append(out, u)
}
}
return out, nil
}
func (s *Service) refreshCachedUsers(ctx context.Context, users ...domain.User) {
ids := make([]int64, 0, len(users))
for _, u := range users {
if u.ID != 0 {
ids = append(ids, u.ID)
}
}
s.dropCachedUsers(ctx, ids...)
s.putCachedUsers(ctx, users...)
}
func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
if s.cache == nil || len(users) == 0 {
return
}
cacheable := make([]domain.User, 0, len(users))
for _, user := range users {
// Collectible ownership may change inside the star-gift aggregate. Keep
// these uncommon users on the authoritative store path so the database
// lifecycle trigger can never be masked by a stale base-user cache entry.
//
// redisstore.userBaseValue has no field for Deleted/DeletedAt/Status: caching
// a deleted user silently resets Deleted to false (and Status to the zero
// UserStatusUnknown) on every round trip, which never self-heals -- each
// subsequent miss reloads the correctly tombstoned DB row and immediately
// re-corrupts it on write. That regressed a deleted account back to looking
// live (blank name, but "last seen recently" instead of "Deleted Account").
// Keep deleted users off the cache so lookups always hit the authoritative
// store, which encodes the tombstone correctly.
if user.ID != 0 && user.EmojiStatusCollectible.Empty() && !user.Deleted {
cacheable = append(cacheable, user)
}
}
if len(cacheable) > 0 {
_ = s.cache.PutMany(ctx, cacheable)
}
}
func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {
if s.cache == nil || len(userIDs) == 0 {
return
}
_ = s.cache.Delete(ctx, userIDs)
}
// InvalidateUsers drops viewer-independent user snapshots after an aggregate
// transaction updates users without passing through this service.
func (s *Service) InvalidateUsers(ctx context.Context, userIDs ...int64) {
if s == nil {
return
}
s.dropCachedUsers(ctx, userIDs...)
}
func uniqueUserIDs(ids []int64, limit int) []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)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func (s *Service) projectOne(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) {
if s == nil || s.projector == nil {
return user, nil
}
return s.projector.One(ctx, viewerUserID, user)
}
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
}