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

@@ -1,9 +1,11 @@
package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -12,16 +14,21 @@ import (
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
)
// avatarUserRepository interface for dependency inversion (DIP)
type avatarUserRepository interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
Update(ctx context.Context, user *domain.User) error
}
// AvatarHandler handles avatar upload requests
type AvatarHandler struct {
userRepo *repository.UserRepository
userRepo avatarUserRepository
}
// NewAvatarHandler creates a new AvatarHandler
func NewAvatarHandler(userRepo *repository.UserRepository) *AvatarHandler {
func NewAvatarHandler(userRepo avatarUserRepository) *AvatarHandler {
return &AvatarHandler{userRepo: userRepo}
}
@@ -107,12 +114,37 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
}
defer src.Close()
// Validate Magic Bytes to detect actual file type (prevents file extension spoofing)
buf := make([]byte, 512)
n, err := src.Read(buf)
if err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "failed to read file"})
return
}
contentType := http.DetectContentType(buf[:n])
allowedMIME := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
if !allowedMIME[contentType] {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid file content, allowed: jpeg, png, gif, webp"})
return
}
// Seek back to beginning for full file read
if _, err := src.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read file"})
return
}
// Generate unique filename
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(8), ext)
uploadDir := "./uploads/avatars"
// Create upload directory if not exists
if err := os.MkdirAll(uploadDir, 0755); err != nil {
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to create upload directory"})
return
}
@@ -124,7 +156,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"})
return
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
return
}

View File

@@ -14,38 +14,37 @@ import (
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/domain"
apierrors "github.com/user-management-system/internal/pkg/errors"
"github.com/user-management-system/internal/repository"
)
// Interfaces for dependency inversion (DIP) — middleware depends on these abstractions, not concrete types.
type authUserRepository interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
}
type authUserRoleRepository interface {
GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*domain.Role, []*domain.Permission, error)
}
type AuthMiddleware struct {
jwt *auth.JWT
userRepo *repository.UserRepository
userRoleRepo *repository.UserRoleRepository
roleRepo *repository.RoleRepository
rolePermissionRepo *repository.RolePermissionRepository
permissionRepo *repository.PermissionRepository
l1Cache *cache.L1Cache
cacheManager *cache.CacheManager
sfGroup singleflight.Group
jwt *auth.JWT
userRepo authUserRepository
userRoleRepo authUserRoleRepository
l1Cache *cache.L1Cache
cacheManager *cache.CacheManager
sfGroup singleflight.Group
}
func NewAuthMiddleware(
jwt *auth.JWT,
userRepo *repository.UserRepository,
userRoleRepo *repository.UserRoleRepository,
roleRepo *repository.RoleRepository,
rolePermissionRepo *repository.RolePermissionRepository,
permissionRepo *repository.PermissionRepository,
userRepo authUserRepository,
userRoleRepo authUserRoleRepository,
l1Cache *cache.L1Cache,
) *AuthMiddleware {
return &AuthMiddleware{
jwt: jwt,
userRepo: userRepo,
userRoleRepo: userRoleRepo,
roleRepo: roleRepo,
rolePermissionRepo: rolePermissionRepo,
permissionRepo: permissionRepo,
l1Cache: l1Cache,
jwt: jwt,
userRepo: userRepo,
userRoleRepo: userRoleRepo,
l1Cache: l1Cache,
}
}
@@ -69,7 +68,7 @@ func (m *AuthMiddleware) Required() gin.HandlerFunc {
return
}
if m.isJTIBlacklisted(claims.JTI) {
if m.isJTIBlacklisted(c.Request.Context(), claims.JTI) {
c.JSON(http.StatusUnauthorized, apierrors.New(http.StatusUnauthorized, "UNAUTHORIZED", "令牌已失效,请重新登录"))
c.Abort()
return
@@ -98,7 +97,7 @@ func (m *AuthMiddleware) Optional() gin.HandlerFunc {
token := m.extractToken(c)
if token != "" {
claims, err := m.jwt.ValidateAccessToken(token)
if err == nil && !m.isJTIBlacklisted(claims.JTI) && m.isUserActive(c.Request.Context(), claims.UserID) {
if err == nil && !m.isJTIBlacklisted(c.Request.Context(), claims.JTI) && m.isUserActive(c.Request.Context(), claims.UserID) {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("token_jti", claims.JTI)
@@ -112,7 +111,7 @@ func (m *AuthMiddleware) Optional() gin.HandlerFunc {
}
}
func (m *AuthMiddleware) isJTIBlacklisted(jti string) bool {
func (m *AuthMiddleware) isJTIBlacklisted(ctx context.Context, jti string) bool {
if jti == "" {
return false
}
@@ -128,7 +127,7 @@ func (m *AuthMiddleware) isJTIBlacklisted(jti string) bool {
// 多个并发请求只会触发一次 L2 查询
if m.cacheManager != nil {
val, err, _ := m.sfGroup.Do(key, func() (interface{}, error) {
found, _ := m.cacheManager.Get(context.Background(), key)
found, _ := m.cacheManager.Get(ctx, key)
return found, nil
})
if err == nil && val != nil {