fix(merchant): 支持重新创建已停用员工用户名
This commit is contained in:
@@ -179,6 +179,8 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
|
||||
}
|
||||
var user model.User
|
||||
createdUser := false
|
||||
reactivated := false
|
||||
previousDefault := false
|
||||
if in.UserID != 0 {
|
||||
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
|
||||
return errors.New("用户不存在或已禁用")
|
||||
@@ -195,29 +197,56 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Where("username = ?", username).First(&user).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
existingMember, reuseErr := reusableDisabledMember(tx, merchantID, &user)
|
||||
if reuseErr != nil {
|
||||
return reuseErr
|
||||
}
|
||||
previousDefault = existingMember.IsDefault
|
||||
updates := map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": model.RoleMerchant,
|
||||
}
|
||||
if nickname := strings.TrimSpace(in.Nickname); nickname != "" {
|
||||
updates["nickname"] = nickname
|
||||
}
|
||||
if err := tx.Model(&user).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
reactivated = true
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
user = model.User{Username: username, PasswordHash: string(hash), Nickname: fallbackName(in.Nickname, username), Role: model.RoleMerchant, Status: 1}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return errors.New("员工用户名已存在")
|
||||
}
|
||||
createdUser = true
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
member.UserID = user.ID
|
||||
// 新建员工仅属于当前商户,登录时应直接进入该商户。
|
||||
member.IsDefault = in.IsDefault || createdUser
|
||||
// 新建或重新启用员工时,确保登录后进入当前商户。
|
||||
member.IsDefault = in.IsDefault || createdUser || previousDefault
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"role": in.Role,
|
||||
"status": 1,
|
||||
"is_default": in.IsDefault,
|
||||
"is_default": member.IsDefault,
|
||||
}),
|
||||
}).Create(member).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), map[string]string{"role": in.Role})
|
||||
metadata := map[string]string{"role": in.Role}
|
||||
if reactivated {
|
||||
metadata["reactivated"] = "true"
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), metadata)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -228,6 +257,26 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// reusableDisabledMember 返回当前商户下可被“添加员工”重新启用的成员关系。
|
||||
// 仅允许本商户找回自己停用的普通员工;启用中的成员、负责人和其他商户的账号
|
||||
// 仍然保持用户名占用,避免通过重建账号接管他人身份。
|
||||
func reusableDisabledMember(tx *gorm.DB, merchantID uint, user *model.User) (*model.MerchantMember, error) {
|
||||
if user.Role != model.RoleMerchant || user.Status != 1 {
|
||||
return nil, errors.New("员工用户名已存在")
|
||||
}
|
||||
var member model.MerchantMember
|
||||
if err := tx.Where("merchant_id = ? AND user_id = ?", merchantID, user.ID).First(&member).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("员工用户名已存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if member.Role == model.MemberRoleOwner || member.Status == 1 {
|
||||
return nil, errors.New("员工用户名已存在")
|
||||
}
|
||||
return &member, nil
|
||||
}
|
||||
|
||||
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
|
||||
var members []model.MerchantMember
|
||||
if err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error; err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -351,3 +352,123 @@ func TestUpdateMemberCanChangeNickname(t *testing.T) {
|
||||
t.Fatalf("nickname should be kept, got %q", user.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMemberReactivatesDisabledMemberWithSameUsername(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
svc := NewMerchantService(db, nil, NewTenantService(db))
|
||||
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "reuse-merchant", "reuse-owner", 1)
|
||||
seedOperatorRole(t, db, merchantID)
|
||||
|
||||
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash old password: %v", err)
|
||||
}
|
||||
staff := model.User{
|
||||
Username: "kf_10", PasswordHash: string(oldHash), Nickname: "旧姓名",
|
||||
Role: model.RoleMerchant, Status: 1, Phone: "13800001234",
|
||||
}
|
||||
if err := db.Create(&staff).Error; err != nil {
|
||||
t.Fatalf("create staff: %v", err)
|
||||
}
|
||||
member := model.MerchantMember{
|
||||
MerchantID: merchantID, UserID: staff.ID, Role: "support", Status: 1, IsDefault: true,
|
||||
}
|
||||
if err := db.Create(&member).Error; err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
if err := db.Model(&member).Update("status", 0).Error; err != nil {
|
||||
t.Fatalf("disable member: %v", err)
|
||||
}
|
||||
member.Status = 0
|
||||
|
||||
created, err := svc.AddMember(merchantID, AddMemberInput{
|
||||
Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator,
|
||||
}, ownerUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("re-add disabled member: %v", err)
|
||||
}
|
||||
if created.UserID != staff.ID {
|
||||
t.Fatalf("reused user id = %d, want %d", created.UserID, staff.ID)
|
||||
}
|
||||
if created.Status != 1 || created.Role != model.MemberRoleOperator || !created.IsDefault {
|
||||
t.Fatalf("unexpected reactivated member: %+v", created)
|
||||
}
|
||||
if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" {
|
||||
t.Fatalf("unexpected reactivated user: %+v", created.User)
|
||||
}
|
||||
var reloaded model.User
|
||||
if err := db.First(&reloaded, staff.ID).Error; err != nil {
|
||||
t.Fatalf("reload staff: %v", err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("yaochao11")); err != nil {
|
||||
t.Fatalf("new password should work: %v", err)
|
||||
}
|
||||
if reloaded.Phone != "13800001234" {
|
||||
t.Fatalf("existing phone binding should be preserved, got %q", reloaded.Phone)
|
||||
}
|
||||
var memberCount int64
|
||||
if err := db.Model(&model.MerchantMember{}).
|
||||
Where("merchant_id = ? AND user_id = ?", merchantID, staff.ID).
|
||||
Count(&memberCount).Error; err != nil {
|
||||
t.Fatalf("count memberships: %v", err)
|
||||
}
|
||||
if memberCount != 1 {
|
||||
t.Fatalf("membership count = %d, want 1", memberCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMemberDoesNotReclaimActiveOrForeignAccount(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
svc := NewMerchantService(db, nil, NewTenantService(db))
|
||||
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "reclaim-a", "reclaim-owner-a", 1)
|
||||
merchantB, ownerBUserID := seedMerchantWithOwner(t, db, "reclaim-b", "reclaim-owner-b", 1)
|
||||
seedOperatorRole(t, db, merchantA)
|
||||
seedOperatorRole(t, db, merchantB)
|
||||
|
||||
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash old password: %v", err)
|
||||
}
|
||||
|
||||
active := model.User{Username: "active-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
|
||||
if err := db.Create(&active).Error; err != nil {
|
||||
t.Fatalf("create active staff: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.MerchantMember{
|
||||
MerchantID: merchantA, UserID: active.ID, Role: model.MemberRoleOperator, Status: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create active member: %v", err)
|
||||
}
|
||||
if _, err := svc.AddMember(merchantA, AddMemberInput{
|
||||
Username: "active-staff", Password: "newpass123", Role: model.MemberRoleOperator,
|
||||
}, ownerAUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
|
||||
t.Fatalf("active account must not be re-added, got %v", err)
|
||||
}
|
||||
|
||||
foreign := model.User{Username: "foreign-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
|
||||
if err := db.Create(&foreign).Error; err != nil {
|
||||
t.Fatalf("create foreign staff: %v", err)
|
||||
}
|
||||
foreignMember := model.MerchantMember{
|
||||
MerchantID: merchantA, UserID: foreign.ID, Role: model.MemberRoleOperator, Status: 1,
|
||||
}
|
||||
if err := db.Create(&foreignMember).Error; err != nil {
|
||||
t.Fatalf("create foreign member: %v", err)
|
||||
}
|
||||
if err := db.Model(&foreignMember).Update("status", 0).Error; err != nil {
|
||||
t.Fatalf("disable foreign member: %v", err)
|
||||
}
|
||||
if _, err := svc.AddMember(merchantB, AddMemberInput{
|
||||
Username: "foreign-staff", Password: "newpass123", Role: model.MemberRoleOperator,
|
||||
}, ownerBUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
|
||||
t.Fatalf("another merchant must not reclaim the account, got %v", err)
|
||||
}
|
||||
|
||||
var reloaded model.User
|
||||
if err := db.First(&reloaded, foreign.ID).Error; err != nil {
|
||||
t.Fatalf("reload foreign staff: %v", err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("oldpass123")); err != nil {
|
||||
t.Fatalf("foreign account password must remain unchanged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user