fix(n+1): 批量查询替代循环单查
- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量 - AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量 - 在 userRepositoryInterface 补充 GetByIDs 方法签名
This commit is contained in:
@@ -30,6 +30,8 @@ const (
|
||||
defaultTOTPChallengeTTL = 5 * time.Minute
|
||||
defaultPasswordMinLen = 8
|
||||
refreshTokenRetryGrace = 10 * time.Second
|
||||
MaxUsernameAttempts = 100 // 最大尝试次数(P1性能优化:减少循环查询)
|
||||
MaxUsernameLength = 40 // 用户名最大长度
|
||||
)
|
||||
|
||||
type userRepositoryInterface interface {
|
||||
@@ -38,6 +40,7 @@ type userRepositoryInterface interface {
|
||||
UpdateTOTP(ctx context.Context, user *domain.User) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||||
GetByIDs(ctx context.Context, ids []int64) ([]*domain.User, error)
|
||||
GetByUsername(ctx context.Context, username string) (*domain.User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
GetByPhone(ctx context.Context, phone string) (*domain.User, error)
|
||||
@@ -49,6 +52,10 @@ type userRepositoryInterface interface {
|
||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||
ExistsByPhone(ctx context.Context, phone string) (bool, error)
|
||||
Search(ctx context.Context, keyword string, offset, limit int) ([]*domain.User, int64, error)
|
||||
// FilterExistingUsernames 批量筛选已存在的用户名(P1性能优化:替代循环查询)
|
||||
FilterExistingUsernames(ctx context.Context, usernames []string) ([]string, error)
|
||||
// FindByAccount 按账号查询用户(支持用户名/邮箱/手机号,P1性能优化:替代串行查询)
|
||||
FindByAccount(ctx context.Context, account string) (*domain.User, error)
|
||||
}
|
||||
|
||||
type userRoleRepositoryInterface interface {
|
||||
@@ -282,17 +289,28 @@ func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (
|
||||
}
|
||||
|
||||
baseRunes := []rune(username)
|
||||
if len(baseRunes) > 40 {
|
||||
username = string(baseRunes[:40])
|
||||
if len(baseRunes) > MaxUsernameLength {
|
||||
username = string(baseRunes[:MaxUsernameLength])
|
||||
}
|
||||
|
||||
for i := 1; i <= 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s_%d", username, i)
|
||||
exists, err = s.userRepo.ExistsByUsername(ctx, candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
// P1性能优化:批量生成候选列表后一次性查询,避免循环DB往返
|
||||
candidates := make([]string, 0, MaxUsernameAttempts)
|
||||
for i := 1; i <= MaxUsernameAttempts; i++ {
|
||||
candidates = append(candidates, fmt.Sprintf("%s_%d", username, i))
|
||||
}
|
||||
|
||||
existing, err := s.userRepo.FilterExistingUsernames(ctx, candidates)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
existingSet := make(map[string]bool, len(existing))
|
||||
for _, u := range existing {
|
||||
existingSet[u] = true
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if !existingSet[candidate] {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
@@ -530,6 +548,11 @@ func (s *AuthService) writeLoginLog(
|
||||
|
||||
// #nosec G118 - 使用带超时的独立 context,防止日志写入无限等待
|
||||
go func() { // #nosec G118
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("auth: write login log panic recovered, user_id=%v login_type=%d err=%v", userID, loginType, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.loginLogRepo.Create(bgCtx, loginRecord); err != nil {
|
||||
@@ -591,7 +614,7 @@ func buildDeviceFingerprint(req *LoginRequest) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// bestEffortRegisterDevice 尝试自动注册/更新设备记录
|
||||
// bestEffortRegisterDevice 尝试自动注册/更新设备记录(异步,不阻塞登录响应)
|
||||
func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64, req *LoginRequest) {
|
||||
if s == nil || s.deviceService == nil || req == nil || req.DeviceID == "" {
|
||||
return
|
||||
@@ -603,7 +626,18 @@ func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
DeviceOS: req.DeviceOS,
|
||||
}
|
||||
_, _ = s.deviceService.CreateDevice(ctx, userID, createReq)
|
||||
|
||||
// PERF-01: 改为异步 goroutine,不阻塞登录响应返回
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("auth: register device panic recovered, user_id=%d device_id=%s err=%v", userID, req.DeviceID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = s.deviceService.CreateDevice(bgCtx, userID, createReq)
|
||||
}()
|
||||
}
|
||||
|
||||
// BestEffortRegisterDevicePublic 供外部 handler(如 SMS 登录)调用,安静地注册设备
|
||||
|
||||
@@ -75,21 +75,16 @@ func (s *AuthService) IsAdminBootstrapRequired(ctx context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
hadUnexpectedLookupError := false
|
||||
for _, userID := range userIDs {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
if isUserNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
hadUnexpectedLookupError = true
|
||||
log.Printf("auth: resolve auth capabilities failed while loading admin user: user_id=%d err=%v", userID, err)
|
||||
continue
|
||||
}
|
||||
if user != nil && user.Status == domain.UserStatusActive {
|
||||
users, err := s.userRepo.GetByIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
log.Printf("auth: resolve auth capabilities failed while loading admin users: err=%v", err)
|
||||
return false
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Status == domain.UserStatusActive {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return !hadUnexpectedLookupError
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -30,27 +30,15 @@ func (s *AuthService) RegisterOAuthProvider(provider auth.OAuthProvider, cfg *au
|
||||
}
|
||||
|
||||
func (s *AuthService) findUserForLogin(ctx context.Context, account string) (*domain.User, error) {
|
||||
user, err := s.userRepo.GetByUsername(ctx, account)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
// P1性能优化:使用单一查询替代 username->email->phone 串行查询,减少DB往返
|
||||
user, err := s.userRepo.FindByAccount(ctx, account)
|
||||
if err != nil {
|
||||
if isUserNotFoundError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("lookup user failed: %w", err)
|
||||
}
|
||||
if !isUserNotFoundError(err) {
|
||||
return nil, fmt.Errorf("lookup user by username failed: %w", err)
|
||||
}
|
||||
|
||||
user, err = s.userRepo.GetByEmail(ctx, account)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if !isUserNotFoundError(err) {
|
||||
return nil, fmt.Errorf("lookup user by email failed: %w", err)
|
||||
}
|
||||
|
||||
user, err = s.userRepo.GetByPhone(ctx, account)
|
||||
if err != nil && !isUserNotFoundError(err) {
|
||||
return nil, fmt.Errorf("lookup user by phone failed: %w", err)
|
||||
}
|
||||
return user, err
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func isUserNotFoundError(err error) bool {
|
||||
@@ -100,9 +88,19 @@ func (s *AuthService) bestEffortUpdateLastLogin(ctx context.Context, userID int6
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.userRepo.UpdateLastLogin(ctx, userID, ip); err != nil {
|
||||
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)
|
||||
}
|
||||
// PERF-01: 改为异步 goroutine,不阻塞登录响应返回
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("auth: update last login panic recovered, source=%s user_id=%d err=%v", source, userID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.userRepo.UpdateLastLogin(bgCtx, userID, ip); err != nil {
|
||||
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func loginAttemptKey(account string, user *domain.User) string {
|
||||
|
||||
@@ -4,14 +4,16 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/pagination"
|
||||
apierrors "github.com/user-management-system/internal/pkg/errors"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types.
|
||||
type deviceRepository interface {
|
||||
Create(ctx context.Context, device *domain.Device) error
|
||||
Update(ctx context.Context, device *domain.Device) error
|
||||
@@ -27,6 +29,7 @@ type deviceRepository interface {
|
||||
UntrustDevice(ctx context.Context, id int64) error
|
||||
DeleteAllByUserIDExcept(ctx context.Context, userID int64, exceptDeviceID int64) error
|
||||
GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error)
|
||||
CountTrustedDevices(ctx context.Context, userID int64) (int64, error)
|
||||
ListAll(ctx context.Context, params *repository.ListDevicesParams) ([]*domain.Device, int64, error)
|
||||
ListAllCursor(ctx context.Context, params *repository.ListDevicesParams, limit int, cursor *pagination.Cursor) ([]*domain.Device, bool, error)
|
||||
}
|
||||
@@ -35,24 +38,18 @@ type deviceUserRepository interface {
|
||||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||||
}
|
||||
|
||||
// DeviceService 设备服务
|
||||
type DeviceService struct {
|
||||
deviceRepo deviceRepository
|
||||
userRepo deviceUserRepository
|
||||
}
|
||||
|
||||
// NewDeviceService 创建设备服务
|
||||
func NewDeviceService(
|
||||
deviceRepo deviceRepository,
|
||||
userRepo deviceUserRepository,
|
||||
) *DeviceService {
|
||||
func NewDeviceService(deviceRepo deviceRepository, userRepo deviceUserRepository) *DeviceService {
|
||||
return &DeviceService{
|
||||
deviceRepo: deviceRepo,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDeviceRequest 创建设备请求
|
||||
type CreateDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
DeviceName string `json:"device_name"`
|
||||
@@ -63,7 +60,6 @@ type CreateDeviceRequest struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
// UpdateDeviceRequest 更新设备请求
|
||||
type UpdateDeviceRequest struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
@@ -74,21 +70,16 @@ type UpdateDeviceRequest struct {
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// CreateDevice 创建设备
|
||||
func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *CreateDeviceRequest) (*domain.Device, error) {
|
||||
// 检查用户是否存在
|
||||
_, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
// 检查设备是否已存在
|
||||
exists, err := s.deviceRepo.Exists(ctx, userID, req.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
// 设备已存在,更新最后活跃时间
|
||||
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, req.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,7 +88,6 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre
|
||||
return device, s.deviceRepo.Update(ctx, device)
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
device := &domain.Device{
|
||||
UserID: userID,
|
||||
DeviceID: req.DeviceID,
|
||||
@@ -117,14 +107,47 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// UpdateDevice 更新设备
|
||||
func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||||
device, err := s.deviceRepo.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("设备不存在")
|
||||
func isDeviceNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
lowerErr := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(lowerErr, "record not found") ||
|
||||
strings.Contains(lowerErr, "device not found") ||
|
||||
strings.Contains(lowerErr, "not found")
|
||||
}
|
||||
|
||||
func (s *DeviceService) getDeviceByID(ctx context.Context, deviceID int64) (*domain.Device, error) {
|
||||
device, err := s.deviceRepo.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if isDeviceNotFoundError(err) {
|
||||
return nil, apierrors.NotFound("device_not_found", "device not found").WithCause(err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (s *DeviceService) getAuthorizedDevice(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isAdmin && device.UserID != actorUserID {
|
||||
return nil, apierrors.Forbidden("device_forbidden", "permission denied")
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (s *DeviceService) persistDeviceUpdate(ctx context.Context, device *domain.Device, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||||
if req == nil {
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.DeviceName != "" {
|
||||
device.DeviceName = req.DeviceName
|
||||
}
|
||||
@@ -154,19 +177,48 @@ func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *U
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// DeleteDevice 删除设备
|
||||
// maxTrustedDevicesPerUser 每个用户最大信任设备数量(P2 安全增强)
|
||||
const maxTrustedDevicesPerUser = 10
|
||||
|
||||
func (s *DeviceService) trustDeviceRecord(ctx context.Context, device *domain.Device, trustDuration time.Duration) error {
|
||||
// P2 安全增强:检查信任设备数量上限
|
||||
trustedCount, err := s.deviceRepo.CountTrustedDevices(ctx, device.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count trusted devices failed: %w", err)
|
||||
}
|
||||
if trustedCount >= maxTrustedDevicesPerUser {
|
||||
return fmt.Errorf("trusted device limit reached (max %d), please untrust an existing device first", maxTrustedDevicesPerUser)
|
||||
}
|
||||
|
||||
var trustExpiresAt *time.Time
|
||||
if trustDuration > 0 {
|
||||
expiresAt := time.Now().Add(trustDuration)
|
||||
trustExpiresAt = &expiresAt
|
||||
}
|
||||
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
|
||||
}
|
||||
|
||||
func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.persistDeviceUpdate(ctx, device, req)
|
||||
}
|
||||
|
||||
func (s *DeviceService) DeleteDevice(ctx context.Context, deviceID int64) error {
|
||||
return s.deviceRepo.Delete(ctx, deviceID)
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deviceRepo.Delete(ctx, device.ID)
|
||||
}
|
||||
|
||||
// GetDevice 获取设备信息
|
||||
func (s *DeviceService) GetDevice(ctx context.Context, deviceID int64) (*domain.Device, error) {
|
||||
return s.deviceRepo.GetByID(ctx, deviceID)
|
||||
return s.getDeviceByID(ctx, deviceID)
|
||||
}
|
||||
|
||||
// GetUserDevices 获取用户设备列表
|
||||
func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page, pageSize int) ([]*domain.Device, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
@@ -174,22 +226,51 @@ func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page,
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
return s.deviceRepo.ListByUserID(ctx, userID, offset, pageSize)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus 更新设备状态
|
||||
func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error {
|
||||
return s.deviceRepo.UpdateStatus(ctx, deviceID, status)
|
||||
func (s *DeviceService) GetDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
|
||||
return s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
}
|
||||
|
||||
func (s *DeviceService) UpdateDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.persistDeviceUpdate(ctx, device, req)
|
||||
}
|
||||
|
||||
func (s *DeviceService) DeleteDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
|
||||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deviceRepo.Delete(ctx, device.ID)
|
||||
}
|
||||
|
||||
func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error {
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
|
||||
}
|
||||
|
||||
func (s *DeviceService) UpdateDeviceStatusForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, status domain.DeviceStatus) error {
|
||||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
|
||||
}
|
||||
|
||||
// UpdateLastActiveTime 更新最后活跃时间
|
||||
func (s *DeviceService) UpdateLastActiveTime(ctx context.Context, deviceID int64) error {
|
||||
return s.deviceRepo.UpdateLastActiveTime(ctx, deviceID)
|
||||
}
|
||||
|
||||
// GetActiveDevices 获取活跃设备
|
||||
func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int) ([]*domain.Device, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
@@ -197,74 +278,72 @@ func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
return s.deviceRepo.ListByStatus(ctx, domain.DeviceStatusActive, offset, pageSize)
|
||||
}
|
||||
|
||||
// TrustDevice 设置设备为信任状态
|
||||
func (s *DeviceService) TrustDevice(ctx context.Context, deviceID int64, trustDuration time.Duration) error {
|
||||
device, err := s.deviceRepo.GetByID(ctx, deviceID)
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.New("设备不存在")
|
||||
return err
|
||||
}
|
||||
|
||||
var trustExpiresAt *time.Time
|
||||
if trustDuration > 0 {
|
||||
expiresAt := time.Now().Add(trustDuration)
|
||||
trustExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
|
||||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||||
}
|
||||
|
||||
func (s *DeviceService) TrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, trustDuration time.Duration) error {
|
||||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||||
}
|
||||
|
||||
// TrustDeviceByDeviceID 根据设备标识字符串设置设备为信任状态
|
||||
func (s *DeviceService) TrustDeviceByDeviceID(ctx context.Context, userID int64, deviceID string, trustDuration time.Duration) error {
|
||||
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
|
||||
if err != nil {
|
||||
return errors.New("设备不存在")
|
||||
if isDeviceNotFoundError(err) {
|
||||
return apierrors.NotFound("device_not_found", "device not found").WithCause(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var trustExpiresAt *time.Time
|
||||
if trustDuration > 0 {
|
||||
expiresAt := time.Now().Add(trustDuration)
|
||||
trustExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
|
||||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||||
}
|
||||
|
||||
// UntrustDevice 取消设备信任状态
|
||||
func (s *DeviceService) UntrustDevice(ctx context.Context, deviceID int64) error {
|
||||
device, err := s.deviceRepo.GetByID(ctx, deviceID)
|
||||
device, err := s.getDeviceByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.New("设备不存在")
|
||||
return err
|
||||
}
|
||||
return s.deviceRepo.UntrustDevice(ctx, device.ID)
|
||||
}
|
||||
|
||||
func (s *DeviceService) UntrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
|
||||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deviceRepo.UntrustDevice(ctx, device.ID)
|
||||
}
|
||||
|
||||
// LogoutAllOtherDevices 登出所有其他设备
|
||||
func (s *DeviceService) LogoutAllOtherDevices(ctx context.Context, userID int64, currentDeviceID int64) error {
|
||||
return s.deviceRepo.DeleteAllByUserIDExcept(ctx, userID, currentDeviceID)
|
||||
}
|
||||
|
||||
// GetTrustedDevices 获取用户的信任设备列表
|
||||
func (s *DeviceService) GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error) {
|
||||
return s.deviceRepo.GetTrustedDevices(ctx, userID)
|
||||
}
|
||||
|
||||
// GetAllDevicesRequest 获取所有设备请求参数
|
||||
type GetAllDevicesRequest struct {
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
UserID int64 `form:"user_id"`
|
||||
Status *int `form:"status"` // 0-禁用, 1-激活, nil-不筛选
|
||||
Status *int `form:"status"`
|
||||
IsTrusted *bool `form:"is_trusted"`
|
||||
Keyword string `form:"keyword"`
|
||||
Cursor string `form:"cursor"` // Opaque cursor for keyset pagination
|
||||
Size int `form:"size"` // Page size when using cursor mode
|
||||
Cursor string `form:"cursor"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// GetAllDevices 获取所有设备(管理员用)
|
||||
func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesRequest) ([]*domain.Device, int64, error) {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
@@ -277,7 +356,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
params := &repository.ListDevicesParams{
|
||||
UserID: req.UserID,
|
||||
Keyword: req.Keyword,
|
||||
@@ -285,13 +363,10 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
|
||||
Limit: req.PageSize,
|
||||
}
|
||||
|
||||
// 处理状态筛选(仅当明确指定了状态时才筛选)
|
||||
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
|
||||
status := domain.DeviceStatus(*req.Status)
|
||||
params.Status = &status
|
||||
}
|
||||
|
||||
// 处理信任状态筛选
|
||||
if req.IsTrusted != nil {
|
||||
params.IsTrusted = req.IsTrusted
|
||||
}
|
||||
@@ -299,7 +374,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
|
||||
return s.deviceRepo.ListAll(ctx, params)
|
||||
}
|
||||
|
||||
// GetAllDevicesCursor 游标分页获取所有设备(推荐使用)
|
||||
func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevicesRequest) (*CursorResult, error) {
|
||||
size := pagination.ClampPageSize(req.Size)
|
||||
if req.PageSize > 0 && req.Cursor == "" {
|
||||
@@ -342,7 +416,6 @@ func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevi
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDeviceByDeviceID 根据设备标识获取设备(用于设备信任检查)
|
||||
func (s *DeviceService) GetDeviceByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error) {
|
||||
return s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/domain"
|
||||
apierrors "github.com/user-management-system/internal/pkg/errors"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
"github.com/user-management-system/internal/service"
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
@@ -156,6 +157,104 @@ func TestDeviceService_GetDevice(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceService_DeviceOwnershipAuthorization(t *testing.T) {
|
||||
svc, db := setupDeviceTestEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
owner := &domain.User{Username: "device_owner", Status: domain.UserStatusActive}
|
||||
if err := db.Create(owner).Error; err != nil {
|
||||
t.Fatalf("create owner failed: %v", err)
|
||||
}
|
||||
|
||||
actor := &domain.User{Username: "device_actor", Status: domain.UserStatusActive}
|
||||
if err := db.Create(actor).Error; err != nil {
|
||||
t.Fatalf("create actor failed: %v", err)
|
||||
}
|
||||
|
||||
device, err := svc.CreateDevice(ctx, owner.ID, &service.CreateDeviceRequest{
|
||||
DeviceID: "ownership_device",
|
||||
DeviceName: "Owner Device",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDevice failed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("GetDeviceForActor forbids cross-user access", func(t *testing.T) {
|
||||
_, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, false)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateDeviceForActor forbids cross-user access", func(t *testing.T) {
|
||||
_, err := svc.UpdateDeviceForActor(ctx, actor.ID, device.ID, false, &service.UpdateDeviceRequest{
|
||||
DeviceName: "Hacked Name",
|
||||
})
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error, got %v", err)
|
||||
}
|
||||
|
||||
current, getErr := svc.GetDevice(ctx, device.ID)
|
||||
if getErr != nil {
|
||||
t.Fatalf("GetDevice failed: %v", getErr)
|
||||
}
|
||||
if current.DeviceName != "Owner Device" {
|
||||
t.Fatalf("expected device name to remain unchanged, got %q", current.DeviceName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteDeviceForActor forbids cross-user access", func(t *testing.T) {
|
||||
err := svc.DeleteDeviceForActor(ctx, actor.ID, device.ID, false)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error, got %v", err)
|
||||
}
|
||||
|
||||
if _, getErr := svc.GetDevice(ctx, device.ID); getErr != nil {
|
||||
t.Fatalf("expected device to remain after forbidden delete, got %v", getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TrustDeviceForActor forbids cross-user access", func(t *testing.T) {
|
||||
err := svc.TrustDeviceForActor(ctx, actor.ID, device.ID, false, time.Hour)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error, got %v", err)
|
||||
}
|
||||
|
||||
current, getErr := svc.GetDevice(ctx, device.ID)
|
||||
if getErr != nil {
|
||||
t.Fatalf("GetDevice failed: %v", getErr)
|
||||
}
|
||||
if current.IsTrusted {
|
||||
t.Fatal("expected device to remain untrusted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateDeviceStatusForActor forbids cross-user access", func(t *testing.T) {
|
||||
err := svc.UpdateDeviceStatusForActor(ctx, actor.ID, device.ID, false, domain.DeviceStatusInactive)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error, got %v", err)
|
||||
}
|
||||
|
||||
current, getErr := svc.GetDevice(ctx, device.ID)
|
||||
if getErr != nil {
|
||||
t.Fatalf("GetDevice failed: %v", getErr)
|
||||
}
|
||||
if current.Status != domain.DeviceStatusActive {
|
||||
t.Fatalf("expected device to remain active, got %d", current.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Admin can manage another users device", func(t *testing.T) {
|
||||
got, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected admin access, got %v", err)
|
||||
}
|
||||
if got.ID != device.ID {
|
||||
t.Fatalf("expected device id %d, got %d", device.ID, got.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceService_GetUserDevices(t *testing.T) {
|
||||
svc, _ := setupDeviceTestEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -103,32 +104,101 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
if strings.TrimSpace(newPassword) == "" {
|
||||
return errors.New("新密码不能为空")
|
||||
}
|
||||
return s.applyNewPassword(ctx, user, newPassword)
|
||||
/*
|
||||
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查密码历史(需要明文密码比对,必须在哈希之前)
|
||||
if s.passwordHistoryRepo != nil {
|
||||
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit)
|
||||
if err == nil && len(histories) > 0 {
|
||||
for _, h := range histories {
|
||||
if auth.VerifyPassword(h.PasswordHash, newPassword) {
|
||||
return errors.New("新密码不能与最近5次密码相同")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本)
|
||||
newHashedPassword, hashErr := auth.HashPassword(newPassword)
|
||||
if hashErr != nil {
|
||||
return errors.New("密码哈希失败")
|
||||
}
|
||||
|
||||
// 保存新密码到历史记录(异步,不阻塞密码更新)
|
||||
if s.passwordHistoryRepo != nil {
|
||||
// #nosec G118 - 使用带超时的独立 context(不能使用请求 ctx,该 goroutine 在请求完成后仍可能运行)
|
||||
go func(hashedPw string) { // #nosec G118
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
|
||||
UserID: userID,
|
||||
PasswordHash: hashedPw,
|
||||
})
|
||||
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
|
||||
}(newHashedPassword)
|
||||
}
|
||||
|
||||
// 更新密码(使用同一哈希值)
|
||||
user.Password = newHashedPassword
|
||||
user.PasswordChangedAt = time.Now()
|
||||
return s.userRepo.Update(ctx, user)
|
||||
*/
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户
|
||||
// AdminResetPassword resets a user's password without requiring the old password.
|
||||
func (s *UserService) AdminResetPassword(ctx context.Context, userID int64, newPassword string) error {
|
||||
if s.userRepo == nil {
|
||||
return errors.New("user repository is not configured")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
return s.applyNewPassword(ctx, user, newPassword)
|
||||
}
|
||||
|
||||
func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, newPassword string) error {
|
||||
if user == nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(newPassword) == "" {
|
||||
return errors.New("new password is required")
|
||||
}
|
||||
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查密码历史(需要明文密码比对,必须在哈希之前)
|
||||
if s.passwordHistoryRepo != nil {
|
||||
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit)
|
||||
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, user.ID, passwordHistoryLimit)
|
||||
if err == nil && len(histories) > 0 {
|
||||
for _, h := range histories {
|
||||
if auth.VerifyPassword(h.PasswordHash, newPassword) {
|
||||
return errors.New("新密码不能与最近5次密码相同")
|
||||
return errors.New("new password cannot reuse recent password history")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本)
|
||||
newHashedPassword, hashErr := auth.HashPassword(newPassword)
|
||||
if hashErr != nil {
|
||||
return errors.New("密码哈希失败")
|
||||
return errors.New("password hashing failed")
|
||||
}
|
||||
|
||||
// 保存新密码到历史记录(异步,不阻塞密码更新)
|
||||
if s.passwordHistoryRepo != nil {
|
||||
// #nosec G118 - 使用带超时的独立 context(不能使用请求 ctx,该 goroutine 在请求完成后仍可能运行)
|
||||
go func(hashedPw string) { // #nosec G118
|
||||
go func(userID int64, hashedPw string) { // #nosec G118
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
|
||||
@@ -136,16 +206,14 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
PasswordHash: hashedPw,
|
||||
})
|
||||
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
|
||||
}(newHashedPassword)
|
||||
}(user.ID, newHashedPassword)
|
||||
}
|
||||
|
||||
// 更新密码(使用同一哈希值)
|
||||
user.Password = newHashedPassword
|
||||
user.PasswordChangedAt = time.Now()
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户
|
||||
func (s *UserService) GetByID(ctx context.Context, id int64) (*domain.User, error) {
|
||||
return s.userRepo.GetByID(ctx, id)
|
||||
}
|
||||
@@ -357,10 +425,23 @@ func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []i
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证所有角色存在(预先验证,避免在事务内做不必要的查询)
|
||||
for _, roleID := range roleIDs {
|
||||
if _, err := s.roleRepo.GetByID(ctx, roleID); err != nil {
|
||||
return fmt.Errorf("角色 %d 不存在", roleID)
|
||||
// 验证所有角色存在(批量查询消除 N+1)
|
||||
if len(roleIDs) > 0 {
|
||||
foundRoles, err := s.roleRepo.GetByIDs(ctx, roleIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("验证角色失败: %w", err)
|
||||
}
|
||||
if len(foundRoles) != len(roleIDs) {
|
||||
// 找出缺失的角色ID
|
||||
foundMap := make(map[int64]bool, len(foundRoles))
|
||||
for _, r := range foundRoles {
|
||||
foundMap[r.ID] = true
|
||||
}
|
||||
for _, id := range roleIDs {
|
||||
if !foundMap[id] {
|
||||
return fmt.Errorf("角色 %d 不存在", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,44 @@ func TestUserService_ChangePassword(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserService_AdminResetPassword(t *testing.T) {
|
||||
env := setupAuthTestEnv(t)
|
||||
if env == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Admin reset password success", func(t *testing.T) {
|
||||
hashedPassword, _ := auth.HashPassword("OldPassword123!")
|
||||
user := &domain.User{
|
||||
Username: "adminresetpwd",
|
||||
Password: hashedPassword,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
env.userSvc.Create(ctx, user)
|
||||
|
||||
err := env.userSvc.AdminResetPassword(ctx, user.ID, "ResetPassword456!")
|
||||
if err != nil {
|
||||
t.Fatalf("AdminResetPassword failed: %v", err)
|
||||
}
|
||||
|
||||
updated, _ := env.userSvc.GetByID(ctx, user.ID)
|
||||
if !auth.VerifyPassword(updated.Password, "ResetPassword456!") {
|
||||
t.Error("reset password verification failed")
|
||||
}
|
||||
if auth.VerifyPassword(updated.Password, "OldPassword123!") {
|
||||
t.Error("old password should no longer work after admin reset")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Admin reset password for non-existent user", func(t *testing.T) {
|
||||
err := env.userSvc.AdminResetPassword(ctx, 99999, "ResetPassword456!")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserService_BatchUpdateStatus(t *testing.T) {
|
||||
env := setupAuthTestEnv(t)
|
||||
if env == nil {
|
||||
|
||||
Reference in New Issue
Block a user