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:
yml2213
2026-10-21 22:37:49 +08:00
parent aa052d420a
commit bab522bcb3
29 changed files with 5490 additions and 1170 deletions
+176 -1
View File
@@ -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:]
}