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