fix profile activity and migration remediation

This commit is contained in:
IanShaw027
2026-04-21 02:08:56 +08:00
parent a27a7add3d
commit ebe7524415
12 changed files with 703 additions and 28 deletions

View File

@@ -161,6 +161,10 @@ type userAuthIdentityReader interface {
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
}
type userProfileIdentityTxRunner interface {
WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password"`
@@ -249,9 +253,38 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs
// UpdateProfile 更新用户资料
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok {
var (
updated *User
oldConcurrency int
)
if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
var err error
updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req)
return err
}); err != nil {
return nil, err
}
if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return updated, nil
}
updated, oldConcurrency, err := s.updateProfile(ctx, userID, req)
if err != nil {
return nil, err
}
if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return updated, nil
}
func (s *UserService) updateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, int, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
return nil, 0, fmt.Errorf("get user: %w", err)
}
oldConcurrency := user.Concurrency
@@ -260,10 +293,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
// 检查新邮箱是否已被使用
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
if err != nil {
return nil, fmt.Errorf("check email exists: %w", err)
return nil, oldConcurrency, fmt.Errorf("check email exists: %w", err)
}
if exists && *req.Email != user.Email {
return nil, ErrEmailExists
return nil, oldConcurrency, ErrEmailExists
}
user.Email = *req.Email
}
@@ -275,7 +308,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if req.AvatarURL != nil {
avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
if err != nil {
return nil, err
return nil, oldConcurrency, err
}
applyUserAvatar(user, avatar)
}
@@ -296,13 +329,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
}
return user, nil
return user, oldConcurrency, nil
}
func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {

View File

@@ -31,13 +31,26 @@ type mockUserRepo struct {
deleteAvatarFn func(ctx context.Context, userID int64) error
deleteAvatarIDs []int64
getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
txCalls int
}
type mockUserRepoTxKey struct{}
type mockUserRepoTxState struct {
getByIDUser *User
upsertAvatarArgs []UpsertUserAvatarInput
deleteAvatarIDs []int64
}
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) {
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
if m.getByIDErr != nil {
return nil, m.getByIDErr
}
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil && txState.getByIDUser != nil {
cloned := *txState.getByIDUser
return &cloned, nil
}
if m.getByIDUser != nil {
cloned := *m.getByIDUser
return &cloned, nil
@@ -61,6 +74,27 @@ func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAv
return nil, nil
}
func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
txState.upsertAvatarArgs = append(txState.upsertAvatarArgs, input)
if txState.getByIDUser != nil {
txState.getByIDUser.AvatarURL = input.URL
txState.getByIDUser.AvatarSource = input.StorageProvider
txState.getByIDUser.AvatarMIME = input.ContentType
txState.getByIDUser.AvatarByteSize = input.ByteSize
txState.getByIDUser.AvatarSHA256 = input.SHA256
}
if m.upsertAvatarFn != nil {
return m.upsertAvatarFn(ctx, userID, input)
}
return &UserAvatar{
StorageProvider: input.StorageProvider,
StorageKey: input.StorageKey,
URL: input.URL,
ContentType: input.ContentType,
ByteSize: input.ByteSize,
SHA256: input.SHA256,
}, nil
}
m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
if m.upsertAvatarFn != nil {
return m.upsertAvatarFn(ctx, userID, input)
@@ -75,6 +109,20 @@ func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input
}, nil
}
func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
txState.deleteAvatarIDs = append(txState.deleteAvatarIDs, userID)
if txState.getByIDUser != nil {
txState.getByIDUser.AvatarURL = ""
txState.getByIDUser.AvatarSource = ""
txState.getByIDUser.AvatarMIME = ""
txState.getByIDUser.AvatarByteSize = 0
txState.getByIDUser.AvatarSHA256 = ""
}
if m.deleteAvatarFn != nil {
return m.deleteAvatarFn(ctx, userID)
}
return nil
}
m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
if m.deleteAvatarFn != nil {
return m.deleteAvatarFn(ctx, userID)
@@ -116,6 +164,26 @@ func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64,
return nil
}
func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
m.txCalls++
txState := &mockUserRepoTxState{
upsertAvatarArgs: append([]UpsertUserAvatarInput(nil), m.upsertAvatarArgs...),
deleteAvatarIDs: append([]int64(nil), m.deleteAvatarIDs...),
}
if m.getByIDUser != nil {
userCopy := *m.getByIDUser
txState.getByIDUser = &userCopy
}
err := fn(context.WithValue(ctx, mockUserRepoTxKey{}, txState))
if err != nil {
return err
}
m.getByIDUser = txState.getByIDUser
m.upsertAvatarArgs = txState.upsertAvatarArgs
m.deleteAvatarIDs = txState.deleteAvatarIDs
return nil
}
// --- mock: APIKeyAuthCacheInvalidator ---
type mockAuthCacheInvalidator struct {
@@ -360,6 +428,33 @@ func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
require.Empty(t, updated.AvatarSource)
}
func TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails(t *testing.T) {
repo := &mockUserRepo{
getByIDUser: &User{
ID: 11,
Email: "rollback@example.com",
AvatarURL: "https://cdn.example.com/original.png",
AvatarSource: "remote_url",
},
updateFn: func(context.Context, *User) error {
return errors.New("write user failed")
},
}
svc := NewUserService(repo, nil, nil, nil)
remoteURL := "https://cdn.example.com/new.png"
_, err := svc.UpdateProfile(context.Background(), 11, UpdateProfileRequest{
AvatarURL: &remoteURL,
})
require.EqualError(t, err, "update user: write user failed")
require.Equal(t, 1, repo.txCalls)
require.Empty(t, repo.upsertAvatarArgs)
require.Empty(t, repo.deleteAvatarIDs)
require.Equal(t, "https://cdn.example.com/original.png", repo.getByIDUser.AvatarURL)
require.Equal(t, "remote_url", repo.getByIDUser.AvatarSource)
}
func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
repo := &mockUserRepo{
getByIDUser: &User{