From 538be52636c34c12edd3fcdb08b0213992eb4f35 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 13 Aug 2026 14:12:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=BB=98=E8=AE=A4=E5=95=86=E6=88=B7=E4=B8=8E=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth.go | 26 +++++++++ backend/internal/router/router.go | 1 + backend/internal/service/auth.go | 68 +++++++++++++++++++++++ backend/internal/service/merchant_test.go | 43 +++++++++++++- backend/internal/service/user.go | 26 +++++++-- frontend/src/api/index.ts | 2 + frontend/src/layouts/MainLayout.tsx | 38 ++++++++----- 7 files changed, 181 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 61231a2..613386d 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -49,6 +49,13 @@ type changePasswordReq struct { NewPassword string `json:"new_password" binding:"required,min=8"` } +type updateCurrentAccountReq struct { + CurrentPassword string `json:"current_password" binding:"required"` + Username string `json:"username" binding:"required"` + Nickname string `json:"nickname"` + NewPassword string `json:"new_password" binding:"omitempty,min=8"` +} + func (h *AuthHandler) ChangePassword(c *gin.Context) { var req changePasswordReq if err := c.ShouldBindJSON(&req); err != nil { @@ -61,3 +68,22 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { } response.OK(c, nil) } + +func (h *AuthHandler) UpdateCurrentAccount(c *gin.Context) { + var req updateCurrentAccountReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写用户名和当前密码;新密码至少 8 位") + return + } + user, err := h.svc.UpdateCurrentAccount(middleware.GetUserID(c), service.UpdateCurrentAccountInput{ + CurrentPassword: req.CurrentPassword, + Username: req.Username, + Nickname: req.Nickname, + NewPassword: req.NewPassword, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, user) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e9d8900..a816633 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -146,6 +146,7 @@ func Setup(h *Handlers) *gin.Engine { auth.Use(middleware.Tenant(h.Tenant)) { auth.GET("/auth/profile", h.Auth.Profile) + auth.PUT("/auth/account", h.Auth.UpdateCurrentAccount) auth.PUT("/auth/password", h.Auth.ChangePassword) auth.GET("/dashboard", h.Dashboard.Dashboard) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index f10e186..08ce0e9 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -26,6 +26,13 @@ type LoginResult struct { User *model.User `json:"user"` } +type UpdateCurrentAccountInput struct { + CurrentPassword string + Username string + Nickname string + NewPassword string +} + func (s *AuthService) Login(username, password string) (*LoginResult, error) { var user model.User if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { @@ -86,6 +93,67 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s return s.db.Model(&user).Update("password_hash", string(hash)).Error } +// UpdateCurrentAccount lets a user maintain their own login identity. The +// current password is always required before username, nickname, or password +// changes are accepted. +func (s *AuthService) UpdateCurrentAccount(userID uint, in UpdateCurrentAccountInput) (*model.User, error) { + if userID == 0 { + return nil, errors.New("无效的用户身份") + } + in.Username = strings.TrimSpace(in.Username) + if len(in.Username) < 3 || len(in.Username) > 64 { + return nil, errors.New("用户名长度需为 3 至 64 位") + } + if len(in.Nickname) > 64 { + return nil, errors.New("昵称不能超过 64 位") + } + if in.NewPassword != "" && len(in.NewPassword) < 8 { + return nil, errors.New("新密码至少 8 位") + } + + updated := &model.User{} + err := s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)); err != nil { + return errors.New("当前密码错误") + } + if in.Username != user.Username { + var count int64 + if err := tx.Model(&model.User{}).Where("username = ? AND id <> ?", in.Username, user.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("用户名已存在") + } + } + updates := map[string]interface{}{"username": in.Username, "nickname": in.Nickname} + if in.NewPassword != "" { + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.NewPassword)); err == nil { + return errors.New("新密码不能与当前密码相同") + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + updates["password_hash"] = string(hash) + } + if err := tx.Model(&user).Updates(updates).Error; err != nil { + return err + } + return tx.First(updated, user.ID).Error + }) + if err != nil { + return nil, err + } + return updated, nil +} + // EnsureAdmin creates the first platform administrator for an empty database. // Existing administrator accounts are never changed by environment variables. func (s *AuthService) EnsureAdmin(username, password string) error { diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index 68b4492..ca6f89f 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -227,7 +227,7 @@ func TestUserServiceCannotDeleteOrDisableLastAdmin(t *testing.T) { } } -func TestPlatformAdminCRUDDoesNotCreateMerchantMembership(t *testing.T) { +func TestPlatformAdminCRUDUsesHiddenSelfMerchantContext(t *testing.T) { db := newServiceTestDB(t) svc := NewUserService(db, NewTenantService(db)) actor, err := svc.CreatePlatformAdmin("existing-admin", "password123", "现有管理员") @@ -242,8 +242,15 @@ func TestPlatformAdminCRUDDoesNotCreateMerchantMembership(t *testing.T) { if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", admin.ID).Count(&membershipCount).Error; err != nil { t.Fatalf("count merchant memberships: %v", err) } - if membershipCount != 0 { - t.Fatalf("platform admin must not have merchant memberships, count=%d", membershipCount) + if membershipCount != 1 { + t.Fatalf("platform admin should have one hidden default membership, count=%d", membershipCount) + } + var membership model.MerchantMember + if err := db.Where("user_id = ?", admin.ID).First(&membership).Error; err != nil { + t.Fatalf("load default membership: %v", err) + } + if membership.Role != model.MemberRoleOwner || !membership.IsDefault { + t.Fatalf("unexpected default membership: %+v", membership) } updated, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{ Username: "platform-admin-updated", Nickname: "更新后管理员", Password: "new-password", Status: 1, @@ -266,6 +273,36 @@ func TestPlatformAdminCRUDDoesNotCreateMerchantMembership(t *testing.T) { } } +func TestUpdateCurrentAccountCanChangeUsernameNicknameAndPassword(t *testing.T) { + db := newServiceTestDB(t) + tenant := NewTenantService(db) + auth := NewAuthService(db, nil, tenant) + user, err := NewUserService(db, tenant).CreatePlatformAdmin("current-account", "old-password", "旧昵称") + if err != nil { + t.Fatalf("create account: %v", err) + } + updated, err := auth.UpdateCurrentAccount(user.ID, UpdateCurrentAccountInput{ + CurrentPassword: "old-password", + Username: "updated-account", + Nickname: "新昵称", + NewPassword: "new-password", + }) + if err != nil { + t.Fatalf("update current account: %v", err) + } + if updated.Username != "updated-account" || updated.Nickname != "新昵称" { + t.Fatalf("unexpected updated account: %+v", updated) + } + if err := bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")); err != nil { + t.Fatalf("updated password should match: %v", err) + } + if _, err := auth.UpdateCurrentAccount(user.ID, UpdateCurrentAccountInput{ + CurrentPassword: "wrong-password", Username: "should-not-save", Nickname: "", NewPassword: "", + }); err == nil { + t.Fatal("expected current password verification error") + } +} + func TestListMerchantAccountGroupsExcludesPlatformAdmins(t *testing.T) { db := newServiceTestDB(t) merchantA := model.Merchant{Code: "merchant-accounts-a", Name: "商户账号 A", Status: model.MerchantStatusActive} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index b3f13c2..f961e82 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -7,6 +7,7 @@ import ( "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type UserService struct { @@ -96,17 +97,30 @@ func (s *UserService) Create(username, password, nickname, role string, merchant if err := tx.Create(user).Error; err != nil { return err } + if role == model.RoleAdmin && s.tenant != nil { + var selfMerchant model.Merchant + if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + return err + } + member := model.MerchantMember{MerchantID: selfMerchant.ID, UserID: user.ID, Role: model.MemberRoleOwner, Status: user.Status, IsDefault: true} + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": model.MemberRoleOwner, "status": user.Status, "is_default": true, + }), + }).Create(&member).Error; err != nil { + return err + } + return nil + } if s.tenant != nil && merchantID != 0 { memberRole := model.MemberRoleOperator - if role == model.RoleAdmin { - memberRole = model.MemberRoleOwner - } member := model.MerchantMember{ MerchantID: merchantID, UserID: user.ID, Role: memberRole, Status: user.Status, - IsDefault: role == model.RoleAdmin, + IsDefault: false, } if err := tx.Create(&member).Error; err != nil { return err @@ -180,8 +194,8 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe return groups, total, nil } -// CreatePlatformAdmin creates an account with platform-only privileges. It -// deliberately does not assign the account to any merchant. +// CreatePlatformAdmin creates an account with platform privileges and the +// hidden self-operated merchant context required by merchant-side routes. func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) { return s.Create(username, password, nickname, model.RoleAdmin, 0) } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1d09c8a..2373dde 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -40,6 +40,8 @@ export const authApi = { }, changePassword: (data: { current_password: string; new_password: string }) => request.put('/auth/password', data).then((r) => r.data.data), + updateCurrentAccount: (data: { current_password: string; username: string; nickname?: string; new_password?: string }) => + request.put('/auth/account', data).then((r) => r.data.data as User), } export const dashboardApi = { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 5ddc7f2..7912f54 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -219,7 +219,7 @@ export default function MainLayout() { const [collapsed, setCollapsed] = useState(() => { return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' }) - const { user, logout, isAdmin, merchant, merchantPermissions } = useAuth() + const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth() const [passwordOpen, setPasswordOpen] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false) const [passwordForm] = Form.useForm() @@ -281,20 +281,22 @@ export default function MainLayout() { navigate(child.path) } - const submitPasswordChange = async () => { + const submitAccountUpdate = async () => { try { const values = await passwordForm.validateFields() setPasswordSaving(true) - await authApi.changePassword({ + await authApi.updateCurrentAccount({ current_password: values.current_password, - new_password: values.new_password, + username: values.username, + nickname: values.nickname || '', + new_password: values.new_password || undefined, }) + await refreshProfile() setPasswordOpen(false) passwordForm.resetFields() - logout() - navigate('/login') + message.success('账户设置已保存') } catch (e) { - message.error(e instanceof Error ? e.message : '密码修改失败') + message.error(e instanceof Error ? e.message : '保存失败') } finally { setPasswordSaving(false) } @@ -313,10 +315,13 @@ export default function MainLayout() { }, { type: 'divider' }, { - key: 'change-password', + key: 'account-settings', icon: , - label: '修改密码', - onClick: () => setPasswordOpen(true), + label: '账户设置', + onClick: () => { + passwordForm.setFieldsValue({ username: user?.username || '', nickname: user?.nickname || '' }) + setPasswordOpen(true) + }, }, { key: 'logout', @@ -454,10 +459,10 @@ export default function MainLayout() { { setPasswordOpen(false) passwordForm.resetFields() @@ -465,14 +470,19 @@ export default function MainLayout() { destroyOnClose >
+ + + + + + - + ({ validator: (_, value) => !value || getFieldValue('new_password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) }), ]}>