added ability to change user info

This commit is contained in:
onysd 2026-08-05 21:19:50 +03:00
parent cddd341bb2
commit 40d49d5e97
22 changed files with 799 additions and 26 deletions

View file

@ -33,6 +33,10 @@ const (
ActionSetUsername = "account.set_username"
ActionSetUserColor = "account.set_color"
ActionSetUserEmojiStatus = "account.set_emoji_status"
ActionSetProfile = "account.set_profile"
ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar"
ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings"
ActionSetChannelColor = "channel.set_color"
@ -209,6 +213,18 @@ type UsersService interface {
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
// SetPhone force-sets a user's phone number (no code verification).
SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error)
}
// AccountService carries the login-email factor (account_passwords table),
// a separate concern from UsersService's users-table fields.
type AccountService interface {
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
SetLoginEmail(ctx context.Context, userID int64, email string) error
// ClearLoginEmail removes the login email factor entirely.
ClearLoginEmail(ctx context.Context, userID int64) error
}
type StarsService interface {
@ -281,6 +297,11 @@ type OfficialGiftsSource interface {
type AvatarResolver interface {
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
// ValidateAvatarUpload is a pure check (no store writes), used by a dry-run
// preview before CreateAvatarFromBytes actually materializes the avatar.
ValidateAvatarUpload(data []byte) bool
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
}
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
@ -393,7 +414,10 @@ type Dependencies struct {
// BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state.
BotVerification BotVerificationService
Now func() time.Time
// Account carries the login-email factor -- a separate app service from
// Users, since login email lives in account_passwords, not users.
Account AccountService
Now func() time.Time
}
type Service struct {
@ -422,6 +446,7 @@ type Service struct {
rating AccountRatingService
verification VerificationService
botVerification BotVerificationService
account AccountService
now func() time.Time
}
@ -506,6 +531,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.BotVerification != nil {
s.botVerification = deps.BotVerification
}
if deps.Account != nil {
s.account = deps.Account
}
if deps.Now != nil {
s.now = deps.Now
}
@ -889,6 +917,37 @@ type SetUsernameRequest struct {
Username string `json:"username"`
}
// SetProfileRequest updates first/last name. Both are always sent (not
// pointer/omitempty): the admin form always shows and submits both fields
// together, so there is no "leave unset" case to represent here.
type SetProfileRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
type SetPhoneRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Phone string `json:"phone"`
}
// SetLoginEmailRequest force-sets (or, if Email is empty, clears) a user's
// login/signup email.
type SetLoginEmailRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Email string `json:"email"`
}
type SetAccountAvatarRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
}
type SetChannelUsernameRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
@ -1594,6 +1653,151 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm
})
}
// SetProfile force-sets a user's first and last name.
func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
firstName := strings.TrimSpace(req.FirstName)
lastName := strings.TrimSpace(req.LastName)
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
"new_first_name": firstName, "new_last_name": lastName,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
FirstName: firstName, HasFirstName: true,
LastName: lastName, HasLastName: true,
})
if err != nil {
return CommandResult{}, err
}
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "profile updated", Details: details}, nil
})
}
// SetPhone force-sets a user's phone number. Rejects a collision with
// another account's phone (checked by the users service before writing,
// backed by the users_phone_unique_idx constraint as well).
func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
phone := strings.TrimSpace(req.Phone)
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
if err != nil {
return CommandResult{}, err
}
details["updated_phone"] = updated.Phone
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "phone updated", Details: details}, nil
})
}
// SetLoginEmail force-sets (or, if Email is empty, clears) a user's
// login/signup email. Rejects a collision with another account's login
// email (checked by the account service before writing, backed by the
// account_passwords_login_email_lower_unique_idx constraint as well).
func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
}
email := strings.TrimSpace(req.Email)
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"new_login_email": email}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
var err error
if email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, email)
}
if err != nil {
return CommandResult{}, err
}
message := "login email updated"
if email == "" {
message = "login email cleared"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// SetAccountAvatar force-sets a user's current profile photo from raw
// uploaded image bytes, reusing the same avatar rendition pipeline
// (s/a/c sizes) as photos.uploadProfilePhoto.
func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
if req.DryRun {
return CommandResult{Message: "avatar validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
if err != nil {
return CommandResult{Details: details}, err
}
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
if s.users != nil {
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
details["notify_error"] = nerr.Error()
}
}
}
return CommandResult{Message: "avatar updated", Details: details}, nil
})
}
// SetUserColor force-sets or clears a user's name/profile color.
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
if req.UserID <= 0 {
@ -2649,7 +2853,9 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
return s.officialGifts.List(ctx)
}
const maxAccountAvatarBytes = 4 << 20
// MaxAccountAvatarBytes bounds both reading (AccountAvatar) and writing
// (SetAccountAvatar) a user's profile photo through the admin console.
const MaxAccountAvatarBytes = 4 << 20
// AccountAvatar returns an account's current profile photo bytes and detected
// MIME type, mirroring internal/web's public avatar serving (same size
@ -2674,17 +2880,17 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
if len(data) == 0 {
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: maxAccountAvatarBytes + 1,
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
}
if len(data) == 0 || len(data) > maxAccountAvatarBytes {
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
@ -2707,7 +2913,7 @@ func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, b
var inline []byte
switch size.Kind {
case domain.PhotoSizeKindCached:
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
if len(size.Bytes) == 0 || len(size.Bytes) > MaxAccountAvatarBytes {
continue
}
inline = size.Bytes

View file

@ -835,6 +835,39 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
return u, nil
}
func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if update.HasFirstName {
u.FirstName = update.FirstName
}
if update.HasLastName {
u.LastName = update.LastName
}
if update.HasAbout {
u.About = update.About
}
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) SetPhone(_ context.Context, userID int64, phone string) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
for id, existing := range f.users {
if id != userID && existing.Phone == phone {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
}
u.Phone = phone
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
u, ok := f.users[userID]
if !ok {

View file

@ -53,6 +53,10 @@ type Service interface {
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
SetProfile(ctx context.Context, req admin.SetProfileRequest) (admin.CommandResult, error)
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
@ -192,6 +196,10 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
mux.HandleFunc("POST /v1/accounts/set-profile", s.authenticated(s.handleSetProfile))
mux.HandleFunc("POST /v1/accounts/set-phone", s.authenticated(s.handleSetPhone))
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail))
mux.HandleFunc("POST /v1/accounts/set-avatar", s.authenticated(s.handleSetAccountAvatar))
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
@ -384,6 +392,67 @@ func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleSetProfile(w http.ResponseWriter, r *http.Request) {
var req admin.SetProfileRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetProfile(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetPhone(w http.ResponseWriter, r *http.Request) {
var req admin.SetPhoneRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetPhone(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
var req admin.SetLoginEmailRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetLoginEmail(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.SetAccountAvatarRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "avatar file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.SetAccountAvatar(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
var req admin.SetUserColorRequest
if !decodeJSON(w, r, &req) {

View file

@ -483,6 +483,22 @@ func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest)
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetProfile(_ context.Context, req admin.SetProfileRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetPhone(_ context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetLoginEmail(_ context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -140,6 +140,28 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
return s.media.GetDocument(ctx, id)
}
// ValidateAvatarUpload is a pure check (decodes only the image header) so a
// dry-run preview can validate bytes before SetAccountAvatar/CreateAvatarFromBytes
// actually renders and stores the avatar's s/a/c size set.
func (s *Service) ValidateAvatarUpload(data []byte) bool {
if len(data) == 0 {
return false
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
return err == nil && cfg.Width > 0 && cfg.Height > 0
}
// CreateAvatarFromBytes stores already-in-hand image bytes as an avatar Photo
// ('s'/'a'/'c' sizes), for callers that skip the chunked upload.saveFilePart
// transfer regular clients use (e.g. the admin console, which already has the
// full file from a browser upload).
func (s *Service) CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error) {
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createAvatarPhoto(ctx, data, ownerUserID)
}
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('s'/'a'/'c' 尺寸,'a'/'c' 匹配
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {

View file

@ -253,6 +253,35 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
return s.projectOne(ctx, self.ID, u)
}
// SetPhone force-sets a user's phone number (admin use -- no code
// verification, unlike the user-facing verified change-phone flow in
// internal/app/account). Pre-checks availability via ByPhone before writing,
// on top of the store's own unique-constraint backstop.
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
}
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)
}
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)

View file

@ -245,6 +245,25 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
return u, nil
}
func (s *UserStore) UpdatePhone(_ context.Context, userID int64, phone string) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound
}
if phone != "" {
for id, existing := range s.byID {
if id != userID && existing.Phone == phone {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
}
}
u.Phone = phone
s.byID[userID] = u
return u, nil
}
func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -220,6 +220,26 @@ func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName,
return userFromModel(row), nil
}
// UpdatePhone force-sets a user's phone number. Used only by the admin
// panel -- the user-facing change-phone flow (internal/app/account) requires
// a verified code and lives in internal/store/postgres/phone_change.go.
func (s *UserStore) UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
row, err := s.q.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{
ID: userID,
Phone: phone,
})
if err != nil {
if isUniqueConstraint(err, "users_phone_unique_idx") {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("update user phone: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)

View file

@ -20,6 +20,9 @@ type UserStore interface {
Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error)
UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
// UpdatePhone force-sets a user's phone number (admin use -- no code
// verification, unlike the user-facing verified change-phone flow).
UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error)
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
// Create 创建用户并返回分配了 ID 的副本。
Create(ctx context.Context, u domain.User) (domain.User, error)