修复:管理员默认商户与账户设置

This commit is contained in:
yml2213
2026-08-13 14:12:25 +08:00
parent 6dae7fafa8
commit 538be52636
7 changed files with 181 additions and 23 deletions
+26
View File
@@ -49,6 +49,13 @@ type changePasswordReq struct {
NewPassword string `json:"new_password" binding:"required,min=8"` 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) { func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req changePasswordReq var req changePasswordReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -61,3 +68,22 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
} }
response.OK(c, nil) 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)
}
+1
View File
@@ -146,6 +146,7 @@ func Setup(h *Handlers) *gin.Engine {
auth.Use(middleware.Tenant(h.Tenant)) auth.Use(middleware.Tenant(h.Tenant))
{ {
auth.GET("/auth/profile", h.Auth.Profile) auth.GET("/auth/profile", h.Auth.Profile)
auth.PUT("/auth/account", h.Auth.UpdateCurrentAccount)
auth.PUT("/auth/password", h.Auth.ChangePassword) auth.PUT("/auth/password", h.Auth.ChangePassword)
auth.GET("/dashboard", h.Dashboard.Dashboard) auth.GET("/dashboard", h.Dashboard.Dashboard)
+68
View File
@@ -26,6 +26,13 @@ type LoginResult struct {
User *model.User `json:"user"` 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) { func (s *AuthService) Login(username, password string) (*LoginResult, error) {
var user model.User var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { 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 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. // EnsureAdmin creates the first platform administrator for an empty database.
// Existing administrator accounts are never changed by environment variables. // Existing administrator accounts are never changed by environment variables.
func (s *AuthService) EnsureAdmin(username, password string) error { func (s *AuthService) EnsureAdmin(username, password string) error {
+40 -3
View File
@@ -227,7 +227,7 @@ func TestUserServiceCannotDeleteOrDisableLastAdmin(t *testing.T) {
} }
} }
func TestPlatformAdminCRUDDoesNotCreateMerchantMembership(t *testing.T) { func TestPlatformAdminCRUDUsesHiddenSelfMerchantContext(t *testing.T) {
db := newServiceTestDB(t) db := newServiceTestDB(t)
svc := NewUserService(db, NewTenantService(db)) svc := NewUserService(db, NewTenantService(db))
actor, err := svc.CreatePlatformAdmin("existing-admin", "password123", "现有管理员") 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 { if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", admin.ID).Count(&membershipCount).Error; err != nil {
t.Fatalf("count merchant memberships: %v", err) t.Fatalf("count merchant memberships: %v", err)
} }
if membershipCount != 0 { if membershipCount != 1 {
t.Fatalf("platform admin must not have merchant memberships, count=%d", membershipCount) 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{ updated, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{
Username: "platform-admin-updated", Nickname: "更新后管理员", Password: "new-password", Status: 1, 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) { func TestListMerchantAccountGroupsExcludesPlatformAdmins(t *testing.T) {
db := newServiceTestDB(t) db := newServiceTestDB(t)
merchantA := model.Merchant{Code: "merchant-accounts-a", Name: "商户账号 A", Status: model.MerchantStatusActive} merchantA := model.Merchant{Code: "merchant-accounts-a", Name: "商户账号 A", Status: model.MerchantStatusActive}
+20 -6
View File
@@ -7,6 +7,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type UserService struct { 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 { if err := tx.Create(user).Error; err != nil {
return err 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 { if s.tenant != nil && merchantID != 0 {
memberRole := model.MemberRoleOperator memberRole := model.MemberRoleOperator
if role == model.RoleAdmin {
memberRole = model.MemberRoleOwner
}
member := model.MerchantMember{ member := model.MerchantMember{
MerchantID: merchantID, MerchantID: merchantID,
UserID: user.ID, UserID: user.ID,
Role: memberRole, Role: memberRole,
Status: user.Status, Status: user.Status,
IsDefault: role == model.RoleAdmin, IsDefault: false,
} }
if err := tx.Create(&member).Error; err != nil { if err := tx.Create(&member).Error; err != nil {
return err return err
@@ -180,8 +194,8 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe
return groups, total, nil return groups, total, nil
} }
// CreatePlatformAdmin creates an account with platform-only privileges. It // CreatePlatformAdmin creates an account with platform privileges and the
// deliberately does not assign the account to any merchant. // hidden self-operated merchant context required by merchant-side routes.
func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) { func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) {
return s.Create(username, password, nickname, model.RoleAdmin, 0) return s.Create(username, password, nickname, model.RoleAdmin, 0)
} }
+2
View File
@@ -40,6 +40,8 @@ export const authApi = {
}, },
changePassword: (data: { current_password: string; new_password: string }) => changePassword: (data: { current_password: string; new_password: string }) =>
request.put('/auth/password', data).then((r) => r.data.data), 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 = { export const dashboardApi = {
+24 -14
View File
@@ -219,7 +219,7 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState<boolean>(() => { const [collapsed, setCollapsed] = useState<boolean>(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' 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 [passwordOpen, setPasswordOpen] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordForm] = Form.useForm() const [passwordForm] = Form.useForm()
@@ -281,20 +281,22 @@ export default function MainLayout() {
navigate(child.path) navigate(child.path)
} }
const submitPasswordChange = async () => { const submitAccountUpdate = async () => {
try { try {
const values = await passwordForm.validateFields() const values = await passwordForm.validateFields()
setPasswordSaving(true) setPasswordSaving(true)
await authApi.changePassword({ await authApi.updateCurrentAccount({
current_password: values.current_password, 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) setPasswordOpen(false)
passwordForm.resetFields() passwordForm.resetFields()
logout() message.success('账户设置已保存')
navigate('/login')
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '密码修改失败') message.error(e instanceof Error ? e.message : '保存失败')
} finally { } finally {
setPasswordSaving(false) setPasswordSaving(false)
} }
@@ -313,10 +315,13 @@ export default function MainLayout() {
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
key: 'change-password', key: 'account-settings',
icon: <KeyOutlined />, icon: <KeyOutlined />,
label: '修改密码', label: '账户设置',
onClick: () => setPasswordOpen(true), onClick: () => {
passwordForm.setFieldsValue({ username: user?.username || '', nickname: user?.nickname || '' })
setPasswordOpen(true)
},
}, },
{ {
key: 'logout', key: 'logout',
@@ -454,10 +459,10 @@ export default function MainLayout() {
</div> </div>
</Content> </Content>
<Modal <Modal
title="修改密码" title="账户设置"
open={passwordOpen} open={passwordOpen}
confirmLoading={passwordSaving} confirmLoading={passwordSaving}
onOk={submitPasswordChange} onOk={submitAccountUpdate}
onCancel={() => { onCancel={() => {
setPasswordOpen(false) setPasswordOpen(false)
passwordForm.resetFields() passwordForm.resetFields()
@@ -465,14 +470,19 @@ export default function MainLayout() {
destroyOnClose destroyOnClose
> >
<Form form={passwordForm} layout="vertical" style={{ marginTop: 16 }}> <Form form={passwordForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }, { min: 3, message: '用户名至少 3 位' }, { max: 64, message: '用户名不能超过 64 位' }]}>
<Input autoComplete="username" />
</Form.Item>
<Form.Item name="nickname" label="昵称" rules={[{ max: 64, message: '昵称不能超过 64 位' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item name="current_password" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}> <Form.Item name="current_password" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password autoComplete="current-password" /> <Input.Password autoComplete="current-password" />
</Form.Item> </Form.Item>
<Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 8, message: '新密码至少 8 位' }]}> <Form.Item name="new_password" label="新密码" rules={[{ min: 8, message: '新密码至少 8 位' }]} extra="留空表示不修改密码">
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
<Form.Item name="confirm_password" label="确认新密码" dependencies={['new_password']} rules={[ <Form.Item name="confirm_password" label="确认新密码" dependencies={['new_password']} rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({ validator: (_, value) => !value || getFieldValue('new_password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) }), ({ getFieldValue }) => ({ validator: (_, value) => !value || getFieldValue('new_password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) }),
]}> ]}>
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />