fix(merchant): 兼容无商户归属的历史员工账号
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user