feat: complete production readiness improvements
- Fix DIP violations in service layer (device, stats, auth middleware) - Add ReplaceUserRoles interface method for transaction safety - Implement Magic Bytes validation for avatar uploads - Standardize OAuth error handling with ErrOAuthProviderNotSupported - Use crypto/rand for JWT secret generation instead of weak fixed key - Apply code formatting with gofumpt and goimports - Fix staticcheck issues (S1024, S1008, ST1005) - Add comprehensive quality and functional test reports - Achieve 36.3% test coverage (up from 16.3%) - All E2E, integration, and business logic tests passing
This commit is contained in:
@@ -87,8 +87,8 @@ type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Remember bool `json:"remember"` // 记住登录
|
||||
DeviceID string `json:"device_id,omitempty"` // 设备唯一标识
|
||||
Remember bool `json:"remember"` // 记住登录
|
||||
DeviceID string `json:"device_id,omitempty"` // 设备唯一标识
|
||||
DeviceName string `json:"device_name,omitempty"` // 设备名称
|
||||
DeviceBrowser string `json:"device_browser,omitempty"` // 浏览器
|
||||
DeviceOS string `json:"device_os,omitempty"` // 操作系统
|
||||
@@ -437,12 +437,12 @@ func (s *AuthService) recordLoginAnomaly(ctx context.Context, userID *int64, ip,
|
||||
}
|
||||
|
||||
s.publishEvent(ctx, domain.EventAnomalyDetected, map[string]interface{}{
|
||||
"user_id": *userID,
|
||||
"ip": ip,
|
||||
"location": location,
|
||||
"device": deviceFingerprint,
|
||||
"events": events,
|
||||
"success": success,
|
||||
"user_id": *userID,
|
||||
"ip": ip,
|
||||
"location": location,
|
||||
"device": deviceFingerprint,
|
||||
"events": events,
|
||||
"success": success,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -787,7 +787,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
|
||||
blacklistKey := tokenBlacklistPrefix + claims.JTI
|
||||
// TTL 设置为 refresh token 的剩余有效期
|
||||
if claims.ExpiresAt != nil {
|
||||
remaining := claims.ExpiresAt.Time.Sub(time.Now())
|
||||
remaining := time.Until(claims.ExpiresAt.Time)
|
||||
if remaining > 0 {
|
||||
_ = s.cache.Set(ctx, blacklistKey, "1", 5*time.Minute, remaining)
|
||||
}
|
||||
|
||||
@@ -91,9 +91,5 @@ func (s *AuthService) IsAdminBootstrapRequired(ctx context.Context) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if hadUnexpectedLookupError {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !hadUnexpectedLookupError
|
||||
}
|
||||
|
||||
@@ -11,16 +11,40 @@ import (
|
||||
"github.com/user-management-system/internal/repository"
|
||||
)
|
||||
|
||||
// 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
|
||||
Delete(ctx context.Context, id int64) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.Device, error)
|
||||
GetByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error)
|
||||
Exists(ctx context.Context, userID int64, deviceID string) (bool, error)
|
||||
ListByUserID(ctx context.Context, userID int64, offset, limit int) ([]*domain.Device, int64, error)
|
||||
ListByStatus(ctx context.Context, status domain.DeviceStatus, offset, limit int) ([]*domain.Device, int64, error)
|
||||
UpdateStatus(ctx context.Context, id int64, status domain.DeviceStatus) error
|
||||
UpdateLastActiveTime(ctx context.Context, id int64) error
|
||||
TrustDevice(ctx context.Context, id int64, expiresAt *time.Time) error
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
type deviceUserRepository interface {
|
||||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||||
}
|
||||
|
||||
// DeviceService 设备服务
|
||||
type DeviceService struct {
|
||||
deviceRepo *repository.DeviceRepository
|
||||
userRepo *repository.UserRepository
|
||||
deviceRepo deviceRepository
|
||||
userRepo deviceUserRepository
|
||||
}
|
||||
|
||||
// NewDeviceService 创建设备服务
|
||||
func NewDeviceService(
|
||||
deviceRepo *repository.DeviceRepository,
|
||||
userRepo *repository.UserRepository,
|
||||
deviceRepo deviceRepository,
|
||||
userRepo deviceUserRepository,
|
||||
) *DeviceService {
|
||||
return &DeviceService{
|
||||
deviceRepo: deviceRepo,
|
||||
@@ -30,24 +54,24 @@ func NewDeviceService(
|
||||
|
||||
// CreateDeviceRequest 创建设备请求
|
||||
type CreateDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
IP string `json:"ip"`
|
||||
Location string `json:"location"`
|
||||
IP string `json:"ip"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
// UpdateDeviceRequest 更新设备请求
|
||||
type UpdateDeviceRequest struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
IP string `json:"ip"`
|
||||
Location string `json:"location"`
|
||||
Status int `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
Location string `json:"location"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// CreateDevice 创建设备
|
||||
@@ -75,15 +99,15 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre
|
||||
|
||||
// 创建设备
|
||||
device := &domain.Device{
|
||||
UserID: userID,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceType: domain.DeviceType(req.DeviceType),
|
||||
DeviceOS: req.DeviceOS,
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
IP: req.IP,
|
||||
Location: req.Location,
|
||||
Status: domain.DeviceStatusActive,
|
||||
UserID: userID,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceType: domain.DeviceType(req.DeviceType),
|
||||
DeviceOS: req.DeviceOS,
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
IP: req.IP,
|
||||
Location: req.Location,
|
||||
Status: domain.DeviceStatusActive,
|
||||
}
|
||||
|
||||
if err := s.deviceRepo.Create(ctx, device); err != nil {
|
||||
|
||||
@@ -20,6 +20,18 @@ const (
|
||||
ExportFormatXLSX = "xlsx"
|
||||
)
|
||||
|
||||
// Interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types.
|
||||
type exportUserRepository interface {
|
||||
List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error)
|
||||
AdvancedSearch(ctx context.Context, filter *repository.AdvancedFilter) ([]*domain.User, int64, error)
|
||||
ExistsByUsername(ctx context.Context, username string) (bool, error)
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
}
|
||||
|
||||
type exportRoleRepository interface {
|
||||
// Reserved for future use (role assignment during import)
|
||||
}
|
||||
|
||||
// ExportUsersRequest defines the supported export filters and output options.
|
||||
type ExportUsersRequest struct {
|
||||
Format string
|
||||
@@ -53,14 +65,14 @@ var defaultExportColumns = []exportColumn{
|
||||
|
||||
// ExportService 用户数据导入导出服务
|
||||
type ExportService struct {
|
||||
userRepo *repository.UserRepository
|
||||
roleRepo *repository.RoleRepository
|
||||
userRepo exportUserRepository
|
||||
roleRepo exportRoleRepository
|
||||
}
|
||||
|
||||
// NewExportService 创建导入导出服务
|
||||
func NewExportService(
|
||||
userRepo *repository.UserRepository,
|
||||
roleRepo *repository.RoleRepository,
|
||||
userRepo exportUserRepository,
|
||||
roleRepo exportRoleRepository,
|
||||
) *ExportService {
|
||||
return &ExportService{
|
||||
userRepo: userRepo,
|
||||
@@ -461,13 +473,13 @@ func parseCSVRecords(data []byte) ([][]string, error) {
|
||||
func parseXLSXRecords(data []byte) ([][]string, error) {
|
||||
file, err := excelize.OpenReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Excel 解析失败: %w", err)
|
||||
return nil, fmt.Errorf("excel parse failed: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
sheets := file.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("Excel 文件没有可用工作表")
|
||||
return nil, fmt.Errorf("excel file has no available sheets")
|
||||
}
|
||||
|
||||
rows, err := file.GetRows(sheets[0])
|
||||
|
||||
@@ -5,19 +5,29 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
)
|
||||
|
||||
// Interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types.
|
||||
type statsUserRepository interface {
|
||||
List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error)
|
||||
ListByStatus(ctx context.Context, status domain.UserStatus, offset, limit int) ([]*domain.User, int64, error)
|
||||
ListCreatedAfter(ctx context.Context, since time.Time, offset, limit int) ([]*domain.User, int64, error)
|
||||
}
|
||||
|
||||
type statsLoginLogRepository interface {
|
||||
CountByResultSince(ctx context.Context, success bool, since time.Time) int64
|
||||
}
|
||||
|
||||
// StatsService 统计服务
|
||||
type StatsService struct {
|
||||
userRepo *repository.UserRepository
|
||||
loginLogRepo *repository.LoginLogRepository
|
||||
userRepo statsUserRepository
|
||||
loginLogRepo statsLoginLogRepository
|
||||
}
|
||||
|
||||
// NewStatsService 创建统计服务
|
||||
func NewStatsService(
|
||||
userRepo *repository.UserRepository,
|
||||
loginLogRepo *repository.LoginLogRepository,
|
||||
userRepo statsUserRepository,
|
||||
loginLogRepo statsLoginLogRepository,
|
||||
) *StatsService {
|
||||
return &StatsService{
|
||||
userRepo: userRepo,
|
||||
|
||||
@@ -38,6 +38,7 @@ type userRoleRepository interface {
|
||||
GetByRoleID(ctx context.Context, roleID int64) ([]*domain.UserRole, error)
|
||||
GetUserIDByRoleID(ctx context.Context, roleID int64) ([]int64, error)
|
||||
BatchCreate(ctx context.Context, userRoles []*domain.UserRole) error
|
||||
ReplaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error
|
||||
DB() *gorm.DB
|
||||
}
|
||||
|
||||
@@ -55,10 +56,10 @@ type passwordHistoryRepository interface {
|
||||
|
||||
// UserService 用户服务
|
||||
type UserService struct {
|
||||
userRepo userRepository
|
||||
userRoleRepo userRoleRepository
|
||||
roleRepo roleRepository
|
||||
passwordHistoryRepo passwordHistoryRepository
|
||||
userRepo userRepository
|
||||
userRoleRepo userRoleRepository
|
||||
roleRepo roleRepository
|
||||
passwordHistoryRepo passwordHistoryRepository
|
||||
}
|
||||
|
||||
const passwordHistoryLimit = 5 // 保留最近5条密码历史
|
||||
@@ -73,7 +74,7 @@ func NewUserService(
|
||||
return &UserService{
|
||||
userRepo: userRepo,
|
||||
userRoleRepo: userRoleRepo,
|
||||
roleRepo: roleRepo,
|
||||
roleRepo: roleRepo,
|
||||
passwordHistoryRepo: passwordHistoryRepo,
|
||||
}
|
||||
}
|
||||
@@ -203,13 +204,13 @@ func (s *UserService) ListCursor(ctx context.Context, req *ListCursorRequest) (*
|
||||
}
|
||||
|
||||
filter := &repository.AdvancedFilter{
|
||||
Keyword: req.Keyword,
|
||||
Status: req.Status,
|
||||
RoleIDs: req.RoleIDs,
|
||||
CreatedFrom: req.CreatedFrom,
|
||||
CreatedTo: req.CreatedTo,
|
||||
SortBy: req.SortBy,
|
||||
SortOrder: req.SortOrder,
|
||||
Keyword: req.Keyword,
|
||||
Status: req.Status,
|
||||
RoleIDs: req.RoleIDs,
|
||||
CreatedFrom: req.CreatedFrom,
|
||||
CreatedTo: req.CreatedTo,
|
||||
SortBy: req.SortBy,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
|
||||
users, hasMore, err := s.userRepo.ListCursor(ctx, filter, size, cursor)
|
||||
@@ -238,8 +239,8 @@ func (s *UserService) UpdateStatus(ctx context.Context, id int64, status domain.
|
||||
|
||||
// BatchUpdateStatusRequest 批量更新状态请求
|
||||
type BatchUpdateStatusRequest struct {
|
||||
IDs []int64 `json:"ids" binding:"required,min=1"`
|
||||
Status domain.UserStatus `json:"status" binding:"required"`
|
||||
IDs []int64 `json:"ids" binding:"required,min=1"`
|
||||
Status domain.UserStatus `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
@@ -305,27 +306,8 @@ func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []i
|
||||
}
|
||||
}
|
||||
|
||||
// 构建新的用户角色关联
|
||||
var userRoles []*domain.UserRole
|
||||
for _, roleID := range roleIDs {
|
||||
userRoles = append(userRoles, &domain.UserRole{
|
||||
UserID: userID,
|
||||
RoleID: roleID,
|
||||
})
|
||||
}
|
||||
|
||||
// 使用事务包装删旧建新操作,确保原子性
|
||||
// Note: WithTx is on concrete type, requires type assertion
|
||||
txRepo, ok := s.userRoleRepo.(*repository.UserRoleRepository)
|
||||
if !ok {
|
||||
return errors.New("userRoleRepo does not support transactions")
|
||||
}
|
||||
return s.userRoleRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := txRepo.WithTx(tx).DeleteByUserID(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return txRepo.WithTx(tx).BatchCreate(ctx, userRoles)
|
||||
})
|
||||
// 使用 Repository 层的事务方法替换用户角色(原子操作)
|
||||
return s.userRoleRepo.ReplaceUserRoles(ctx, userID, roleIDs)
|
||||
}
|
||||
|
||||
// getAdminRoleID looks up the admin role ID by code to avoid hardcoded magic numbers.
|
||||
@@ -451,6 +433,6 @@ func (s *UserService) DeleteAdmin(ctx context.Context, userID int64, currentUser
|
||||
type CreateAdminRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user