Files
backend/internal/handler/customskin_handler.go

244 lines
7.1 KiB
Go
Raw Permalink 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 (
"carrotskin/internal/container"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// CustomSkinHandler CustomSkinAPI处理器
type CustomSkinHandler struct {
container *container.Container
logger *zap.Logger
}
// NewCustomSkinHandler 创建CustomSkinHandler实例
func NewCustomSkinHandler(c *container.Container) *CustomSkinHandler {
return &CustomSkinHandler{
container: c,
logger: c.Logger,
}
}
// CustomSkinAPIResponse CustomSkinAPI响应格式
type CustomSkinAPIResponse struct {
Username string `json:"username"`
Textures map[string]string `json:"textures,omitempty"`
Skin string `json:"skin,omitempty"`
Cape string `json:"cape,omitempty"`
Elytra string `json:"elytra,omitempty"`
}
// GetPlayerInfo 获取玩家信息
// @Summary 获取玩家信息
// @Description CustomSkinAPI: 获取玩家皮肤配置信息
// @Tags CustomSkinAPI
// @Accept json
// @Produce json
// @Param username path string true "玩家用户名"
// @Success 200 {object} CustomSkinAPIResponse
// @Failure 400 {object} map[string]string "用户名不能为空"
// @Failure 404 {object} map[string]string "玩家未找到"
// @Router /api/v1/csl/{username} [get]
func (h *CustomSkinHandler) GetPlayerInfo(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
return
}
// 移除 .json 后缀(如果存在)
username = strings.TrimSuffix(username, ".json")
// 查找Profile不区分大小写
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
if err != nil {
h.logger.Debug("未找到玩家",
zap.String("username", username),
zap.Error(err),
)
c.JSON(http.StatusNotFound, gin.H{"error": "玩家未找到"})
return
}
// 构建响应
response := CustomSkinAPIResponse{
Username: profile.Name,
}
// Profile 已经通过 GetByProfileName 预加载了 Skin 和 Cape
// 构建材质字典
textures := make(map[string]string)
hasSkin := false
hasCape := false
hasElytra := false
// 处理皮肤
if profile.SkinID != nil && profile.Skin != nil {
skinHash := profile.Skin.Hash
hasSkin = true
if profile.Skin.IsSlim {
// 如果是slim模型优先添加到slim然后添加default
textures["slim"] = skinHash
textures["default"] = skinHash
} else {
// 如果是default模型优先添加到default然后添加slim
textures["default"] = skinHash
textures["slim"] = skinHash
}
}
// 处理披风
if profile.CapeID != nil && profile.Cape != nil {
textures["cape"] = profile.Cape.Hash
hasCape = true
}
// 处理鞘翅使用cape的hash如果存在cape
if hasCape && profile.Cape != nil {
textures["elytra"] = profile.Cape.Hash
hasElytra = true
}
// 根据材质字典决定返回格式
// 根据协议如果只有皮肤使用default模型可以使用缩略格式
// 但如果有多个不同的材质或需要指定模型,使用完整格式
if hasSkin && !hasCape && !hasElytra {
// 如果只有皮肤使用缩略格式使用default模型的hash
if defaultHash, exists := textures["default"]; exists {
response.Skin = defaultHash
} else if slimHash, exists := textures["slim"]; exists {
// 如果只有slim也使用缩略格式但协议说这会导致手臂渲染错误
response.Skin = slimHash
}
} else if len(textures) > 0 {
// 如果有多个材质或需要指定模型,使用完整格式
response.Textures = textures
}
// 如果没有材质,不设置 textures 和 skin 字段(留空)
// 设置缓存头
c.Header("Cache-Control", "public, max-age=300") // 5分钟缓存
c.Header("Content-Type", "application/json; charset=utf-8")
// 响应If-Modified-Since
if modifiedSince := c.GetHeader("If-Modified-Since"); modifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, modifiedSince); err == nil {
// 如果资源未修改返回304
if profile.UpdatedAt.Before(t.Add(time.Second)) {
c.Status(http.StatusNotModified)
return
}
}
}
// 设置Last-Modified
c.Header("Last-Modified", profile.UpdatedAt.UTC().Format(http.TimeFormat))
c.JSON(http.StatusOK, response)
}
// GetTexture 获取资源文件
// @Summary 获取资源文件
// @Description CustomSkinAPI: 获取材质图片文件
// @Tags CustomSkinAPI
// @Param hash path string true "材质Hash"
// @Success 200 {file} binary
// @Failure 400 {object} map[string]string "资源标识符不能为空"
// @Failure 404 {object} map[string]string "资源未找到或不可用"
// @Router /api/v1/csl/textures/{hash} [get]
func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
hash := c.Param("hash")
if hash == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "资源标识符不能为空"})
return
}
// 查找Texture
texture, err := h.container.TextureService.GetByHash(c.Request.Context(), hash)
if err != nil {
h.logger.Debug("未找到材质",
zap.String("hash", hash),
zap.Error(err),
)
c.JSON(http.StatusNotFound, gin.H{"error": "资源未找到"})
return
}
// 检查材质状态
if texture.Status != 1 {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不可用"})
return
}
// 解析文件URL获取bucket和objectName
if h.container.Storage == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储服务不可用"})
return
}
bucket, objectName, err := h.container.Storage.ParseFileURL(texture.URL)
if err != nil {
h.logger.Error("解析文件URL失败",
zap.String("url", texture.URL),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析文件URL失败"})
return
}
// 获取文件对象
ctx := c.Request.Context()
reader, objInfo, err := h.container.Storage.GetObject(ctx, bucket, objectName)
if err != nil {
h.logger.Error("获取文件失败",
zap.String("bucket", bucket),
zap.String("objectName", objectName),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取文件失败"})
return
}
defer reader.Close()
// 设置HTTP头
c.Header("Content-Type", objInfo.ContentType)
c.Header("Content-Length", fmt.Sprintf("%d", objInfo.Size))
c.Header("Last-Modified", objInfo.LastModified.UTC().Format(http.TimeFormat))
c.Header("ETag", objInfo.ETag)
c.Header("Cache-Control", "public, max-age=86400") // 24小时缓存
// 响应If-Modified-Since
if modifiedSince := c.GetHeader("If-Modified-Since"); modifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, modifiedSince); err == nil {
// 如果资源未修改返回304
if objInfo.LastModified.Before(t.Add(time.Second)) {
c.Status(http.StatusNotModified)
return
}
}
}
// 响应If-None-Match (ETag)
if noneMatch := c.GetHeader("If-None-Match"); noneMatch != "" {
if noneMatch == objInfo.ETag || noneMatch == fmt.Sprintf(`"%s"`, objInfo.ETag) {
c.Status(http.StatusNotModified)
return
}
}
// 增加下载计数(异步)
go func() {
_ = h.container.TextureRepo.IncrementDownloadCount(ctx, texture.ID)
}()
// 流式传输文件内容
c.DataFromReader(http.StatusOK, objInfo.Size, objInfo.ContentType, reader, nil)
}