added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue