Files
user-system/internal/api/handler/avatar_handler.go

191 lines
5.8 KiB
Go
Raw Normal View History

package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
apimiddleware "github.com/user-management-system/internal/api/middleware"
"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, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes)[:length], nil
}
// 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
if currentUserID != userID && !apimiddleware.IsAdmin(c) {
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
token, err := generateSecureToken(8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate avatar token"})
return
}
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, token, 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,
},
})
}