diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go index 3b4afa3..2d44123 100644 --- a/backend/internal/service/merchant_member.go +++ b/backend/internal/service/merchant_member.go @@ -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 } - 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("员工用户名已存在") + 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 } - createdUser = true } 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 { diff --git a/backend/internal/service/merchant_member_security_test.go b/backend/internal/service/merchant_member_security_test.go index cb0c23f..d2ab57b 100644 --- a/backend/internal/service/merchant_member_security_test.go +++ b/backend/internal/service/merchant_member_security_test.go @@ -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) + } +}