From 26d7635ca03b474b12c0c6cdf532a7ce52c918e7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 22 Oct 2026 09:55:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(merchant):=20=E5=85=BC=E5=AE=B9=E6=97=A0?= =?UTF-8?q?=E5=95=86=E6=88=B7=E5=BD=92=E5=B1=9E=E7=9A=84=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/merchant_member.go | 66 ++++++++++++++----- .../service/merchant_member_security_test.go | 62 +++++++++++++++++ 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go index 2d44123..fee44a1 100644 --- a/backend/internal/service/merchant_member.go +++ b/backend/internal/service/merchant_member.go @@ -181,6 +181,7 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse createdUser := false reactivated := false previousDefault := false + var replacedUserID uint if in.UserID != 0 { if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil { return errors.New("用户不存在或已禁用") @@ -200,22 +201,40 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse err = tx.Where("username = ?", username).First(&user).Error switch { case err == nil: - existingMember, reuseErr := reusableDisabledMember(tx, merchantID, &user) + existingMember, reuseErr := reusableMemberForUsername(tx, merchantID, &user) if reuseErr != nil { return reuseErr } - previousDefault = existingMember.IsDefault - updates := map[string]interface{}{ - "password_hash": string(hash), - "role": model.RoleMerchant, + if existingMember != nil { + // 本商户停用的员工:复用原账号,重置初始密码并重新启用。 + 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 + break } - if nickname := strings.TrimSpace(in.Nickname); nickname != "" { - updates["nickname"] = nickname - } - if err := tx.Model(&user).Updates(updates).Error; err != nil { + // 历史遗留的无商户归属账号:先释放手机号与设备绑定并软删除, + // 再按“新建员工”创建全新账号,避免旧绑定被新员工继承。 + replacedUserID = user.ID + if err := releaseUserIdentity(tx, user.ID); err != nil { return err } - reactivated = true + if err := tx.Delete(&user).Error; 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("员工用户名已存在") + } + createdUser = 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 { @@ -246,6 +265,9 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse if reactivated { metadata["reactivated"] = "true" } + if replacedUserID != 0 { + metadata["replaced_user_id"] = fmt.Sprint(replacedUserID) + } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), metadata) }) if err != nil { @@ -257,24 +279,32 @@ 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) { +// reusableMemberForUsername 判断同名账号能否被当前商户重新使用。 +// 本商户已停用的普通员工返回成员关系;无任何商户归属的历史账号返回 nil, +// 由调用方退役旧账号后重建;启用中的成员、负责人和其他商户的账号直接拒绝。 +func reusableMemberForUsername(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) { + err := tx.Where("merchant_id = ? AND user_id = ?", merchantID, user.ID).First(&member).Error + if err == nil { + if member.Role == model.MemberRoleOwner || member.Status == 1 { return nil, errors.New("员工用户名已存在") } + return &member, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - if member.Role == model.MemberRoleOwner || member.Status == 1 { + var membershipCount int64 + if err := tx.Model(&model.MerchantMember{}).Where("user_id = ?", user.ID).Count(&membershipCount).Error; err != nil { + return nil, err + } + if membershipCount > 0 { return nil, errors.New("员工用户名已存在") } - return &member, nil + return nil, nil } func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { diff --git a/backend/internal/service/merchant_member_security_test.go b/backend/internal/service/merchant_member_security_test.go index d2ab57b..ef47af3 100644 --- a/backend/internal/service/merchant_member_security_test.go +++ b/backend/internal/service/merchant_member_security_test.go @@ -472,3 +472,65 @@ func TestAddMemberDoesNotReclaimActiveOrForeignAccount(t *testing.T) { t.Fatalf("foreign account password must remain unchanged: %v", err) } } + +func TestAddMemberReplacesOrphanedAccountWithSameUsername(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "orphan-merchant", "orphan-owner", 1) + seedOperatorRole(t, db, merchantID) + + oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash old password: %v", err) + } + orphan := model.User{ + Username: "kf_10", PasswordHash: string(oldHash), Nickname: "历史账号", + Role: model.RoleMerchant, Status: 1, Phone: "13800005678", + } + if err := db.Create(&orphan).Error; err != nil { + t.Fatalf("create orphan user: %v", err) + } + now := time.Now() + device := model.UserDevice{ + UserID: orphan.ID, DeviceID: "D1-ORPH-AN00-0000-0001", PublicKey: "pk", + ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive, + BoundAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&device).Error; err != nil { + t.Fatalf("create orphan device: %v", err) + } + + created, err := svc.AddMember(merchantID, AddMemberInput{ + Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator, + }, ownerUserID) + if err != nil { + t.Fatalf("recreate orphaned account: %v", err) + } + if created.UserID == orphan.ID { + t.Fatal("orphaned account should be replaced by a fresh user row") + } + if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" { + t.Fatalf("unexpected new user: %+v", created.User) + } + if err := bcrypt.CompareHashAndPassword([]byte(created.User.PasswordHash), []byte("yaochao11")); err != nil { + t.Fatalf("new password should work: %v", err) + } + + var retired model.User + if err := db.Unscoped().First(&retired, orphan.ID).Error; err != nil { + t.Fatalf("reload retired user: %v", err) + } + if !retired.DeletedAt.Valid { + t.Fatal("orphaned account should be soft-deleted") + } + if retired.Phone != "" { + t.Fatalf("orphaned account phone should be released, got %q", retired.Phone) + } + var reloadedDevice model.UserDevice + if err := db.First(&reloadedDevice, device.ID).Error; err != nil { + t.Fatalf("reload device: %v", err) + } + if reloadedDevice.Status != model.UserDeviceStatusRevoked { + t.Fatalf("orphaned account device should be revoked, got %q", reloadedDevice.Status) + } +}