feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
This commit is contained in:
@@ -2,6 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
@@ -142,7 +145,16 @@ type UpdatePlatformAdminInput struct {
|
||||
}
|
||||
|
||||
func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, error) {
|
||||
return s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin})
|
||||
list, total, err := s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pointers := make([]*model.User, 0, len(list))
|
||||
for i := range list {
|
||||
pointers = append(pointers, &list[i])
|
||||
}
|
||||
s.enrichLoginSecurity(pointers)
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// ListMerchantAccountGroups returns merchant employee accounts grouped by
|
||||
@@ -191,6 +203,13 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe
|
||||
}
|
||||
group.Members = append(group.Members, member)
|
||||
}
|
||||
pointers := make([]*model.User, 0, len(members))
|
||||
for i := range members {
|
||||
if members[i].User != nil {
|
||||
pointers = append(pointers, members[i].User)
|
||||
}
|
||||
}
|
||||
s.enrichLoginSecurity(pointers)
|
||||
return groups, total, nil
|
||||
}
|
||||
|
||||
@@ -317,6 +336,22 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
|
||||
})
|
||||
}
|
||||
|
||||
// releaseUserIdentity 在账号软删除前释放手机号与设备绑定。
|
||||
// users.phone 的唯一索引不区分软删除,不清理会让该手机号永远无法再绑定。
|
||||
func releaseUserIdentity(tx *gorm.DB, userID uint) error {
|
||||
now := time.Now()
|
||||
if err := tx.Model(&model.UserDevice{}).
|
||||
Where("user_id = ? AND status = ?", userID, model.UserDeviceStatusActive).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.UserDeviceStatusRevoked,
|
||||
"revoked_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("phone", "").Error
|
||||
}
|
||||
|
||||
// DeleteMerchantAccount permanently removes a merchant login account and all
|
||||
// of its merchant memberships. Platform administrators must be managed from
|
||||
// the dedicated platform administrator view instead.
|
||||
@@ -341,6 +376,146 @@ func (s *UserService) DeleteMerchantAccount(id, actorUserID uint) error {
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseUserIdentity(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&user).Error
|
||||
})
|
||||
}
|
||||
|
||||
// BindUserPhone 由管理员为账号绑定/修改登录手机号(手机号全平台唯一)。
|
||||
func (s *UserService) BindUserPhone(id uint, phoneValue string, actorUserID uint) (*model.User, error) {
|
||||
if id == 0 {
|
||||
return nil, newAuthError(404, "user_not_found", "用户不存在")
|
||||
}
|
||||
phone, err := NormalizePhone(phoneValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated := &model.User{}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.First(&user, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return newAuthError(404, "user_not_found", "用户不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if user.Phone == phone {
|
||||
updated = &user
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("phone = ? AND id <> ?", phone, user.ID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return newAuthError(409, "phone_exists", "该手机号已绑定其他账号")
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&user).Updates(map[string]interface{}{
|
||||
"phone": phone,
|
||||
"phone_bound_at": now,
|
||||
"phone_bound_by": actorUserID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, nil, &actorUserID, nil, "user.phone.bind", "user", fmt.Sprint(user.ID), map[string]interface{}{
|
||||
"phone": MaskPhone(phone),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(updated, user.ID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.enrichLoginSecurity([]*model.User{updated})
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// ResetUserDevice 由管理员撤销账号当前 active 的 DCAuth 设备绑定。
|
||||
func (s *UserService) ResetUserDevice(id uint, actorUserID uint) (bool, error) {
|
||||
if id == 0 {
|
||||
return false, newAuthError(404, "user_not_found", "用户不存在")
|
||||
}
|
||||
var reset bool
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.First(&user, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return newAuthError(404, "user_not_found", "用户不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
result := tx.Model(&model.UserDevice{}).
|
||||
Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.UserDeviceStatusRevoked,
|
||||
"revoked_at": now,
|
||||
"revoked_by": actorUserID,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
reset = result.RowsAffected > 0
|
||||
return writeAudit(tx, nil, &actorUserID, nil, "user.device.reset", "user", fmt.Sprint(user.ID), map[string]interface{}{
|
||||
"revoked_devices": result.RowsAffected,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return reset, nil
|
||||
}
|
||||
|
||||
// enrichLoginSecurity 批量补充手机号掩码与 active 设备信息,避免列表 N+1 查询。
|
||||
func (s *UserService) enrichLoginSecurity(users []*model.User) {
|
||||
if len(users) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]uint, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user == nil {
|
||||
continue
|
||||
}
|
||||
user.PhoneMasked = MaskPhone(user.Phone)
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
var devices []model.UserDevice
|
||||
if err := s.db.Where("user_id IN ? AND status = ?", ids, model.UserDeviceStatusActive).Find(&devices).Error; err != nil {
|
||||
return
|
||||
}
|
||||
byUser := make(map[uint]model.UserDevice, len(devices))
|
||||
for _, device := range devices {
|
||||
byUser[device.UserID] = device
|
||||
}
|
||||
for _, user := range users {
|
||||
if user == nil {
|
||||
continue
|
||||
}
|
||||
device, ok := byUser[user.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user.DeviceBound = true
|
||||
user.DeviceID = device.DeviceID
|
||||
user.DeviceIDMasked = MaskDeviceID(device.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
// MaskDeviceID 返回 D1-ABC-****-1234 形式的设备码。
|
||||
func MaskDeviceID(value string) string {
|
||||
deviceID := strings.ToUpper(strings.TrimSpace(value))
|
||||
if len(deviceID) <= 8 {
|
||||
return deviceID
|
||||
}
|
||||
return deviceID[:6] + "-****-" + deviceID[len(deviceID)-4:]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user