fix(merchant): 兼容无商户归属的历史员工账号

This commit is contained in:
yml2213
2026-10-22 09:55:13 +08:00
parent 5c883702bb
commit 26d7635ca0
2 changed files with 110 additions and 18 deletions
+48 -18
View File
@@ -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) {
@@ -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)
}
}