Files
user-system/internal/api/handler/avatar_handler.go
long-agent 09beb173cc 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
2026-04-12 16:15:32 +08:00

193 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/domain"
)
// 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 avatarUserRepository
}
// NewAvatarHandler creates a new AvatarHandler
func NewAvatarHandler(userRepo avatarUserRepository) *AvatarHandler {
return &AvatarHandler{userRepo: userRepo}
}
// generateSecureToken generates a secure random token
func generateSecureToken(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)[:length]
}
// UploadAvatar 上传用户头像
// @Summary 上传用户头像
// @Description 上传并更新用户头像(仅本人或管理员)
// @Tags 用户头像
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param id path int true "用户ID"
// @Param avatar formData file true "头像文件最大5MB支持jpg/jpeg/png/gif/webp"
// @Success 200 {object} Response{data=AvatarResponse} "上传成功"
// @Failure 400 {object} Response "文件无效或大小超限"
// @Failure 401 {object} Response "未认证"
// @Failure 403 {object} Response "无权限"
// @Failure 404 {object} Response "用户不存在"
// @Router /api/v1/users/{id}/avatar [post]
func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid user id"})
return
}
// Get current user from context (set by auth middleware)
currentUserID := c.GetInt64("user_id")
if currentUserID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
return
}
// Check permission: user can only update their own avatar, or admin can update any
isAdmin := false
if roles, ok := c.Get("user_roles"); ok {
for _, role := range roles.([]*domain.Role) {
if role.Code == "admin" {
isAdmin = true
break
}
}
}
if currentUserID != userID && !isAdmin {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
return
}
// Get file from form
file, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "no avatar file provided"})
return
}
// Validate file size (max 5MB)
if file.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "file size exceeds 5MB limit"})
return
}
// Validate file type
ext := filepath.Ext(file.Filename)
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
if !allowedExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid file type, allowed: jpg, jpeg, png, gif, webp"})
return
}
// Open the uploaded file
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to open uploaded file"})
return
}
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, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to create upload directory"})
return
}
// Save file to disk
dstPath := filepath.Join(uploadDir, avatarFilename)
data := make([]byte, file.Size)
if _, err := src.Read(data); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"})
return
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
return
}
// Generate avatar URL (in production, this would be a CDN URL)
avatarURL := fmt.Sprintf("/uploads/avatars/%s", avatarFilename)
// Update user's avatar in database
user, err := h.userRepo.GetByID(c.Request.Context(), userID)
if err != nil {
// Clean up the uploaded file
os.Remove(dstPath)
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "user not found"})
return
}
user.Avatar = avatarURL
if err := h.userRepo.Update(c.Request.Context(), user); err != nil {
// Clean up the uploaded file
os.Remove(dstPath)
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to update user avatar"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "avatar uploaded successfully",
"data": gin.H{
"avatar_url": avatarURL,
"thumbnail": avatarURL,
},
})
}