fix(merchant): 支持重新创建已停用员工用户名

This commit is contained in:
yml2213
2026-10-22 09:52:50 +08:00
parent 6a2b95d300
commit 5c883702bb
2 changed files with 178 additions and 8 deletions
+53 -4
View File
@@ -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)
}
}