fix(n+1): 批量查询替代循环单查

- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量
- AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量
- 在 userRepositoryInterface 补充 GetByIDs 方法签名
This commit is contained in:
2026-05-08 08:05:26 +08:00
parent 9b1cea246e
commit 2a18a6fb47
39 changed files with 3169 additions and 393 deletions

View File

@@ -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 登录)调用,安静地注册设备

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)
}
}
}
}

View File

@@ -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 {