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:
2026-04-12 16:15:32 +08:00
parent 861736cf4d
commit 09beb173cc
22 changed files with 3122 additions and 414 deletions

View File

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

View File

@@ -91,9 +91,5 @@ func (s *AuthService) IsAdminBootstrapRequired(ctx context.Context) bool {
}
}
if hadUnexpectedLookupError {
return false
}
return true
return !hadUnexpectedLookupError
}

View File

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

View File

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

View File

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

View File

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