feat: Add texture rendering endpoints and service methods
- Introduced new API endpoints for rendering textures, avatars, capes, and previews, enhancing the texture handling capabilities. - Implemented corresponding service methods in the TextureHandler to process rendering requests and return appropriate responses. - Updated the TextureRenderService interface to include methods for rendering textures, avatars, and capes, along with their respective parameters. - Enhanced error handling for invalid texture IDs and added support for different rendering types and formats. - Updated go.mod to include the webp library for image processing.
This commit is contained in:
@@ -108,6 +108,10 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
|
||||
// 公开路由(无需认证)
|
||||
textureGroup.GET("", h.Search)
|
||||
textureGroup.GET("/:id", h.Get)
|
||||
textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric
|
||||
textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d
|
||||
textureGroup.GET("/:id/cape", h.RenderCape)
|
||||
textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览
|
||||
|
||||
// 需要认证的路由
|
||||
textureAuth := textureGroup.Group("")
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"strconv"
|
||||
|
||||
@@ -171,6 +172,98 @@ func (h *TextureHandler) Search(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RenderTexture 渲染皮肤/披风预览
|
||||
func (h *TextureHandler) RenderTexture(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
renderType := service.RenderType(c.DefaultQuery("type", string(service.RenderTypeIsometric)))
|
||||
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||
|
||||
result, err := h.container.TextureRenderService.RenderTexture(c.Request.Context(), textureID, renderType, size, format)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, err.Error(), err)
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, toRenderResponse(result))
|
||||
}
|
||||
|
||||
// RenderAvatar 渲染头像(2D/3D)
|
||||
func (h *TextureHandler) RenderAvatar(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
mode := service.AvatarMode(c.DefaultQuery("mode", string(service.AvatarMode2D)))
|
||||
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||
|
||||
result, err := h.container.TextureRenderService.RenderAvatar(c.Request.Context(), textureID, size, mode, format)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, err.Error(), err)
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, toRenderResponse(result))
|
||||
}
|
||||
|
||||
// RenderCape 渲染披风
|
||||
func (h *TextureHandler) RenderCape(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||
|
||||
result, err := h.container.TextureRenderService.RenderCape(c.Request.Context(), textureID, size, format)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, err.Error(), err)
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, toRenderResponse(result))
|
||||
}
|
||||
|
||||
// RenderPreview 自动选择预览(皮肤走等距,披风走披风渲染)
|
||||
func (h *TextureHandler) RenderPreview(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||
|
||||
result, err := h.container.TextureRenderService.RenderPreview(c.Request.Context(), textureID, size, format)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, err.Error(), err)
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, toRenderResponse(result))
|
||||
}
|
||||
|
||||
// toRenderResponse 转换为API响应
|
||||
func toRenderResponse(r *service.RenderResult) *types.RenderResponse {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &types.RenderResponse{
|
||||
URL: r.URL,
|
||||
ContentType: r.ContentType,
|
||||
ETag: r.ETag,
|
||||
Size: r.Size,
|
||||
}
|
||||
if !r.LastModified.IsZero() {
|
||||
t := r.LastModified
|
||||
resp.LastModified = &t
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Update 更新材质
|
||||
func (h *TextureHandler) Update(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
|
||||
@@ -23,3 +23,9 @@ type BaseModel struct {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,3 +29,9 @@ func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (st
|
||||
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,69 @@ type SecurityService interface {
|
||||
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
||||
}
|
||||
|
||||
// TextureRenderService 纹理渲染服务接口
|
||||
type TextureRenderService interface {
|
||||
// RenderTexture 渲染纹理为预览图
|
||||
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
|
||||
// RenderTextureFromData 从原始数据渲染纹理
|
||||
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
|
||||
// GetRenderURL 获取渲染图的URL
|
||||
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
|
||||
// DeleteRenderCache 删除渲染缓存
|
||||
DeleteRenderCache(ctx context.Context, textureID int64) error
|
||||
// RenderAvatar 渲染头像(支持2D/3D模式)
|
||||
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
|
||||
// RenderCape 渲染披风
|
||||
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
||||
// RenderPreview 渲染预览图(类似Blessing Skin的preview功能)
|
||||
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
||||
}
|
||||
|
||||
// RenderType 渲染类型
|
||||
type RenderType string
|
||||
|
||||
const (
|
||||
RenderTypeFront RenderType = "front" // 正面
|
||||
RenderTypeBack RenderType = "back" // 背面
|
||||
RenderTypeFull RenderType = "full" // 全身
|
||||
RenderTypeHead RenderType = "head" // 头像
|
||||
RenderTypeIsometric RenderType = "isometric" // 等距视图
|
||||
)
|
||||
|
||||
// ImageFormat 输出格式
|
||||
type ImageFormat string
|
||||
|
||||
const (
|
||||
ImageFormatPNG ImageFormat = "png"
|
||||
ImageFormatWEBP ImageFormat = "webp"
|
||||
)
|
||||
|
||||
// AvatarMode 头像模式
|
||||
type AvatarMode string
|
||||
|
||||
const (
|
||||
AvatarMode2D AvatarMode = "2d" // 2D头像
|
||||
AvatarMode3D AvatarMode = "3d" // 3D头像
|
||||
)
|
||||
|
||||
// TextureType 纹理类型
|
||||
type TextureType string
|
||||
|
||||
const (
|
||||
TextureTypeSteve TextureType = "steve" // Steve皮肤
|
||||
TextureTypeAlex TextureType = "alex" // Alex皮肤
|
||||
TextureTypeCape TextureType = "cape" // 披风
|
||||
)
|
||||
|
||||
// RenderResult 渲染结果(附带缓存/HTTP头信息)
|
||||
type RenderResult struct {
|
||||
URL string
|
||||
ContentType string
|
||||
ETag string
|
||||
LastModified time.Time
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Services 服务集合
|
||||
type Services struct {
|
||||
User UserService
|
||||
|
||||
@@ -315,6 +315,18 @@ func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*m
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTextureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
|
||||
if m.FailFind {
|
||||
return nil, errors.New("mock find error")
|
||||
}
|
||||
for _, texture := range m.textures {
|
||||
if texture.Hash == hash && texture.UploaderID == uploaderID {
|
||||
return texture, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
if m.FailFind {
|
||||
return nil, 0, errors.New("mock find error")
|
||||
|
||||
701
internal/service/texture_render_service.go
Normal file
701
internal/service/texture_render_service.go
Normal file
@@ -0,0 +1,701 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/storage"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// textureRenderService TextureRenderService的实现
|
||||
type textureRenderService struct {
|
||||
textureRepo repository.TextureRepository
|
||||
storage *storage.StorageClient
|
||||
cache *database.CacheManager
|
||||
cacheKeys *database.CacheKeyBuilder
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewTextureRenderService 创建TextureRenderService实例
|
||||
func NewTextureRenderService(
|
||||
textureRepo repository.TextureRepository,
|
||||
storageClient *storage.StorageClient,
|
||||
cacheManager *database.CacheManager,
|
||||
logger *zap.Logger,
|
||||
) TextureRenderService {
|
||||
return &textureRenderService{
|
||||
textureRepo: textureRepo,
|
||||
storage: storageClient,
|
||||
cache: cacheManager,
|
||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderTexture 渲染纹理为预览图
|
||||
func (s *textureRenderService) RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error) {
|
||||
// 参数验证
|
||||
if size <= 0 || size > 2048 {
|
||||
return nil, errors.New("渲染尺寸必须在1到2048之间")
|
||||
}
|
||||
contentType, err := normalizeFormat(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查缓存(包含格式)
|
||||
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
|
||||
var cached RenderResult
|
||||
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
// 获取纹理信息
|
||||
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||
}
|
||||
if texture == nil {
|
||||
return nil, errors.New("纹理不存在")
|
||||
}
|
||||
|
||||
// 从对象存储获取纹理文件
|
||||
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||
}
|
||||
|
||||
// 渲染纹理
|
||||
renderedImage, _, err := s.RenderTextureFromData(ctx, textureData, renderType, size, format, texture.IsSlim)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("渲染纹理失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存渲染结果到对象存储
|
||||
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, renderType, size, format, renderedImage, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存渲染结果失败: %w", err)
|
||||
}
|
||||
|
||||
// 若源对象有元信息,透传 LastModified/ETag 作为参考
|
||||
if srcInfo != nil {
|
||||
if result.LastModified.IsZero() {
|
||||
result.LastModified = srcInfo.LastModified
|
||||
}
|
||||
if result.ETag == "" {
|
||||
result.ETag = srcInfo.ETag
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果(1小时)
|
||||
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||
s.logger.Warn("缓存渲染结果失败", zap.Error(err))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenderAvatar 渲染头像(支持2D/3D模式)
|
||||
func (s *textureRenderService) RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error) {
|
||||
if size <= 0 || size > 1024 {
|
||||
return nil, errors.New("头像渲染尺寸必须在1到1024之间")
|
||||
}
|
||||
contentType, err := normalizeFormat(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderKey := fmt.Sprintf("avatar-%s", mode)
|
||||
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderKey, format), size)
|
||||
var cached RenderResult
|
||||
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||
}
|
||||
if texture == nil {
|
||||
return nil, errors.New("纹理不存在")
|
||||
}
|
||||
if texture.Type != model.TextureTypeSkin {
|
||||
return nil, errors.New("仅皮肤纹理支持头像渲染")
|
||||
}
|
||||
|
||||
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(textureData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码PNG失败: %w", err)
|
||||
}
|
||||
|
||||
var rendered image.Image
|
||||
switch mode {
|
||||
case AvatarMode3D:
|
||||
rendered = s.renderIsometricView(img, texture.IsSlim, size)
|
||||
default:
|
||||
rendered = s.renderHeadView(img, size)
|
||||
}
|
||||
|
||||
encoded, err := encodeImage(rendered, format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编码渲染头像失败: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType(renderKey), size, format, encoded, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存头像渲染失败: %w", err)
|
||||
}
|
||||
if srcInfo != nil && result.LastModified.IsZero() {
|
||||
result.LastModified = srcInfo.LastModified
|
||||
}
|
||||
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||
s.logger.Warn("缓存头像渲染失败", zap.Error(err))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenderCape 渲染披风
|
||||
func (s *textureRenderService) RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
|
||||
if size <= 0 || size > 2048 {
|
||||
return nil, errors.New("披风渲染尺寸必须在1到2048之间")
|
||||
}
|
||||
contentType, err := normalizeFormat(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("cape:%s", format), size)
|
||||
var cached RenderResult
|
||||
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||
}
|
||||
if texture == nil {
|
||||
return nil, errors.New("纹理不存在")
|
||||
}
|
||||
if texture.Type != model.TextureTypeCape {
|
||||
return nil, errors.New("仅披风纹理支持披风渲染")
|
||||
}
|
||||
|
||||
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(textureData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码PNG失败: %w", err)
|
||||
}
|
||||
|
||||
rendered := s.renderCapeView(img, size)
|
||||
|
||||
encoded, err := encodeImage(rendered, format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编码披风渲染失败: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType("cape"), size, format, encoded, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存披风渲染失败: %w", err)
|
||||
}
|
||||
if srcInfo != nil && result.LastModified.IsZero() {
|
||||
result.LastModified = srcInfo.LastModified
|
||||
}
|
||||
|
||||
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||
s.logger.Warn("缓存披风渲染失败", zap.Error(err))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenderPreview 渲染预览图(类似 Blessing Skin preview)
|
||||
func (s *textureRenderService) RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
|
||||
if size <= 0 || size > 2048 {
|
||||
return nil, errors.New("预览渲染尺寸必须在1到2048之间")
|
||||
}
|
||||
|
||||
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||
}
|
||||
if texture == nil {
|
||||
return nil, errors.New("纹理不存在")
|
||||
}
|
||||
|
||||
switch texture.Type {
|
||||
case model.TextureTypeCape:
|
||||
return s.RenderCape(ctx, textureID, size, format)
|
||||
default:
|
||||
// 使用改进的等距视图作为默认预览
|
||||
return s.RenderTexture(ctx, textureID, RenderTypeIsometric, size, format)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderTextureFromData 从原始数据渲染纹理
|
||||
func (s *textureRenderService) RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error) {
|
||||
// 解码PNG图像
|
||||
img, err := png.Decode(bytes.NewReader(textureData))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("解码PNG失败: %w", err)
|
||||
}
|
||||
|
||||
contentType, err := normalizeFormat(format)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 根据渲染类型处理图像
|
||||
var renderedImage image.Image
|
||||
switch renderType {
|
||||
case RenderTypeFront:
|
||||
renderedImage = s.renderFrontView(img, isSlim, size)
|
||||
case RenderTypeBack:
|
||||
renderedImage = s.renderBackView(img, isSlim, size)
|
||||
case RenderTypeFull:
|
||||
renderedImage = s.renderFullView(img, isSlim, size)
|
||||
case RenderTypeHead:
|
||||
renderedImage = s.renderHeadView(img, size)
|
||||
case RenderTypeIsometric:
|
||||
renderedImage = s.renderIsometricView(img, isSlim, size)
|
||||
default:
|
||||
return nil, "", errors.New("不支持的渲染类型")
|
||||
}
|
||||
|
||||
encoded, err := encodeImage(renderedImage, format)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("编码纹理失败: %w", err)
|
||||
}
|
||||
|
||||
return encoded, contentType, nil
|
||||
}
|
||||
|
||||
// GetRenderURL 获取渲染图的URL
|
||||
func (s *textureRenderService) GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string {
|
||||
// 构建渲染图的存储路径
|
||||
// 格式: renders/{textureID}/{renderType}/{size}.{ext}
|
||||
ext := string(format)
|
||||
if ext == "" {
|
||||
ext = string(ImageFormatPNG)
|
||||
}
|
||||
return fmt.Sprintf("renders/%d/%s/%d.%s", textureID, renderType, size, ext)
|
||||
}
|
||||
|
||||
// DeleteRenderCache 删除渲染缓存
|
||||
func (s *textureRenderService) DeleteRenderCache(ctx context.Context, textureID int64) error {
|
||||
// 删除所有渲染类型与格式的缓存
|
||||
renderTypes := []RenderType{
|
||||
RenderTypeFront, RenderTypeBack, RenderTypeFull, RenderTypeHead,
|
||||
RenderTypeIsometric, RenderType("avatar-2d"), RenderType("avatar-3d"), RenderType("cape"),
|
||||
}
|
||||
formats := []ImageFormat{ImageFormatPNG, ImageFormatWEBP}
|
||||
sizes := []int{64, 128, 256, 512}
|
||||
|
||||
for _, renderType := range renderTypes {
|
||||
for _, size := range sizes {
|
||||
for _, format := range formats {
|
||||
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
|
||||
if err := s.cache.Delete(ctx, cacheKey); err != nil {
|
||||
s.logger.Warn("删除渲染缓存失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadTexture 从对象存储下载纹理
|
||||
func (s *textureRenderService) downloadTexture(ctx context.Context, textureURL string) ([]byte, *storage.ObjectInfo, error) {
|
||||
// 先直接通过 HTTP GET 下载(对公有/匿名可读对象最兼容)
|
||||
if resp, httpErr := http.Get(textureURL); httpErr == nil && resp != nil && resp.StatusCode == http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr == nil {
|
||||
var lm time.Time
|
||||
if t, parseErr := http.ParseTime(resp.Header.Get("Last-Modified")); parseErr == nil {
|
||||
lm = t
|
||||
}
|
||||
return body, &storage.ObjectInfo{
|
||||
Size: resp.ContentLength,
|
||||
LastModified: lm,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 若 HTTP 失败,再尝试通过对象存储 SDK 访问
|
||||
bucket, objectName, err := s.storage.ParseFileURL(textureURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析纹理URL失败: %w", err)
|
||||
}
|
||||
|
||||
reader, info, err := s.storage.GetObject(ctx, bucket, objectName)
|
||||
if err != nil {
|
||||
s.logger.Error("获取纹理对象失败",
|
||||
zap.String("texture_url", textureURL),
|
||||
zap.String("bucket", bucket),
|
||||
zap.String("object", objectName),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, fmt.Errorf("获取纹理对象失败: bucket=%s object=%s err=%v", bucket, objectName, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, readErr := io.ReadAll(reader)
|
||||
if readErr != nil {
|
||||
return nil, nil, readErr
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// saveRenderToStorage 保存渲染结果到对象存储
|
||||
func (s *textureRenderService) saveRenderToStorage(ctx context.Context, textureID int64, textureHash string, renderType RenderType, size int, format ImageFormat, imageData []byte, contentType string) (*RenderResult, error) {
|
||||
// 获取存储桶
|
||||
bucketName, err := s.storage.GetBucket("renders")
|
||||
if err != nil {
|
||||
// 如果renders桶不存在,使用textures桶
|
||||
bucketName, err = s.storage.GetBucket("textures")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textureHash) < 4 {
|
||||
return nil, errors.New("纹理哈希长度不足,无法生成路径")
|
||||
}
|
||||
|
||||
ext := string(format)
|
||||
objectName := fmt.Sprintf("renders/%s/%s/%s_%s_%d.%s",
|
||||
textureHash[:2], textureHash[2:4], textureHash, renderType, size, ext)
|
||||
|
||||
// 上传到对象存储
|
||||
reader := bytes.NewReader(imageData)
|
||||
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(len(imageData)), contentType); err != nil {
|
||||
return nil, fmt.Errorf("上传渲染结果失败: %w", err)
|
||||
}
|
||||
|
||||
etag := sha256.Sum256(imageData)
|
||||
result := &RenderResult{
|
||||
URL: s.storage.BuildFileURL(bucketName, objectName),
|
||||
ContentType: contentType,
|
||||
ETag: hex.EncodeToString(etag[:]),
|
||||
LastModified: time.Now().UTC(),
|
||||
Size: int64(len(imageData)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// renderFrontView 渲染正面视图(分块+第二层,含 Alex/Steve)
|
||||
func (s *textureRenderService) renderFrontView(img image.Image, isSlim bool, size int) image.Image {
|
||||
base := composeFrontModel(img, isSlim)
|
||||
return scaleNearest(base, size, size)
|
||||
}
|
||||
|
||||
// renderBackView 渲染背面视图(分块+第二层)
|
||||
func (s *textureRenderService) renderBackView(img image.Image, isSlim bool, size int) image.Image {
|
||||
base := composeBackModel(img, isSlim)
|
||||
return scaleNearest(base, size, size)
|
||||
}
|
||||
|
||||
// renderFullView 渲染全身视图(正面+背面)
|
||||
func (s *textureRenderService) renderFullView(img image.Image, isSlim bool, size int) image.Image {
|
||||
front := composeFrontModel(img, isSlim)
|
||||
back := composeBackModel(img, isSlim)
|
||||
|
||||
full := image.NewRGBA(image.Rect(0, 0, front.Bounds().Dx()+back.Bounds().Dx(), front.Bounds().Dy()))
|
||||
draw.Draw(full, image.Rect(0, 0, front.Bounds().Dx(), front.Bounds().Dy()), front, image.Point{}, draw.Src)
|
||||
draw.Draw(full, image.Rect(front.Bounds().Dx(), 0, full.Bounds().Dx(), full.Bounds().Dy()), back, image.Point{}, draw.Src)
|
||||
|
||||
return scaleNearest(full, size*2, size)
|
||||
}
|
||||
|
||||
// renderHeadView 渲染头像视图(包含第二层帽子)
|
||||
func (s *textureRenderService) renderHeadView(img image.Image, size int) image.Image {
|
||||
headBase := safeCrop(img, image.Rect(8, 8, 16, 16))
|
||||
headOverlay := safeCrop(img, image.Rect(40, 8, 48, 16))
|
||||
|
||||
if headBase == nil {
|
||||
// 返回空白头像
|
||||
return scaleNearest(image.NewRGBA(image.Rect(0, 0, 8, 8)), size, size)
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, headBase.Bounds().Dx(), headBase.Bounds().Dy()))
|
||||
draw.Draw(canvas, canvas.Bounds(), headBase, headBase.Bounds().Min, draw.Src)
|
||||
if headOverlay != nil {
|
||||
draw.Draw(canvas, canvas.Bounds(), headOverlay, headOverlay.Bounds().Min, draw.Over)
|
||||
}
|
||||
|
||||
return scaleNearest(canvas, size, size)
|
||||
}
|
||||
|
||||
// renderIsometricView 渲染等距视图(改进 3D)
|
||||
func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image {
|
||||
result := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
bgColor := color.RGBA{240, 240, 240, 255}
|
||||
draw.Draw(result, result.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||
|
||||
front := scaleNearest(composeFrontModel(img, isSlim), size/2, size/2)
|
||||
|
||||
for y := 0; y < front.Bounds().Dy(); y++ {
|
||||
for x := 0; x < front.Bounds().Dx(); x++ {
|
||||
destX := x + size/4
|
||||
destY := y + size/4
|
||||
depth := float64(x) / float64(front.Bounds().Dx())
|
||||
brightness := 1.0 - depth*0.25
|
||||
|
||||
c := front.At(x, y)
|
||||
r, g, b, a := c.RGBA()
|
||||
newR := uint32(float64(r) * brightness)
|
||||
newG := uint32(float64(g) * brightness)
|
||||
newB := uint32(float64(b) * brightness)
|
||||
|
||||
if a > 0 {
|
||||
result.Set(destX, destY, color.RGBA64{
|
||||
R: uint16(newR),
|
||||
G: uint16(newG),
|
||||
B: uint16(newB),
|
||||
A: uint16(a),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
borderColor := color.RGBA{200, 200, 200, 255}
|
||||
for i := 0; i < 2; i++ {
|
||||
drawLine(result, size/4, size/4, size*3/4, size/4, borderColor)
|
||||
drawLine(result, size/4, size*3/4, size*3/4, size*3/4, borderColor)
|
||||
drawLine(result, size/4, size/4, size/4, size*3/4, borderColor)
|
||||
drawLine(result, size*3/4, size/4, size*3/4, size*3/4, borderColor)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// drawLine 绘制直线
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
|
||||
dx := abs(x2 - x1)
|
||||
dy := abs(y2 - y1)
|
||||
sx := -1
|
||||
if x1 < x2 {
|
||||
sx = 1
|
||||
}
|
||||
sy := -1
|
||||
if y1 < y2 {
|
||||
sy = 1
|
||||
}
|
||||
err := dx - dy
|
||||
|
||||
for {
|
||||
img.Set(x1, y1, c)
|
||||
if x1 == x2 && y1 == y2 {
|
||||
break
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 > -dy {
|
||||
err -= dy
|
||||
x1 += sx
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx
|
||||
y1 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// abs 绝对值
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// renderCapeView 渲染披风(等比缩放)
|
||||
func (s *textureRenderService) renderCapeView(img image.Image, size int) image.Image {
|
||||
srcBounds := img.Bounds()
|
||||
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
|
||||
return img
|
||||
}
|
||||
targetWidth := size * 2
|
||||
targetHeight := size
|
||||
return scaleNearest(img, targetWidth, targetHeight)
|
||||
}
|
||||
|
||||
// composeFrontModel 组合正面分块(含第二层)
|
||||
func composeFrontModel(img image.Image, isSlim bool) *image.RGBA {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
|
||||
armW := 4
|
||||
if isSlim {
|
||||
armW = 3
|
||||
}
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
|
||||
safeCrop(img, image.Rect(8, 8, 16, 16)),
|
||||
safeCrop(img, image.Rect(40, 8, 48, 16)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
|
||||
safeCrop(img, image.Rect(20, 20, 28, 32)),
|
||||
safeCrop(img, image.Rect(20, 36, 28, 48)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
|
||||
safeCrop(img, image.Rect(44, 20, 48, 32)),
|
||||
safeCrop(img, image.Rect(44, 36, 48, 48)))
|
||||
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
|
||||
safeCrop(img, image.Rect(36, 52, 40, 64)),
|
||||
safeCrop(img, image.Rect(52, 52, 56, 64)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
|
||||
safeCrop(img, image.Rect(4, 20, 8, 32)),
|
||||
safeCrop(img, image.Rect(4, 36, 8, 48)))
|
||||
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
|
||||
safeCrop(img, image.Rect(20, 52, 24, 64)),
|
||||
safeCrop(img, image.Rect(4, 52, 8, 64)))
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
// composeBackModel 组合背面分块(含第二层)
|
||||
func composeBackModel(img image.Image, isSlim bool) *image.RGBA {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
|
||||
armW := 4
|
||||
if isSlim {
|
||||
armW = 3
|
||||
}
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
|
||||
safeCrop(img, image.Rect(24, 8, 32, 16)),
|
||||
safeCrop(img, image.Rect(56, 8, 64, 16)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
|
||||
safeCrop(img, image.Rect(32, 20, 40, 32)),
|
||||
safeCrop(img, image.Rect(32, 36, 40, 48)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
|
||||
safeCrop(img, image.Rect(52, 20, 56, 32)),
|
||||
safeCrop(img, image.Rect(52, 36, 56, 48)))
|
||||
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
|
||||
safeCrop(img, image.Rect(44, 52, 48, 64)),
|
||||
safeCrop(img, image.Rect(60, 52, 64, 64)))
|
||||
|
||||
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
|
||||
safeCrop(img, image.Rect(12, 20, 16, 32)),
|
||||
safeCrop(img, image.Rect(12, 36, 16, 48)))
|
||||
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
|
||||
safeCrop(img, image.Rect(28, 52, 32, 64)),
|
||||
safeCrop(img, image.Rect(12, 52, 16, 64)))
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
// drawLayeredPart 绘制单个分块(基础层+第二层)
|
||||
func drawLayeredPart(dst draw.Image, dstRect image.Rectangle, base image.Image, overlay image.Image) {
|
||||
if base == nil {
|
||||
return
|
||||
}
|
||||
dstW := dstRect.Dx()
|
||||
dstH := dstRect.Dy()
|
||||
|
||||
for y := 0; y < dstH; y++ {
|
||||
for x := 0; x < dstW; x++ {
|
||||
srcX := base.Bounds().Min.X + x*base.Bounds().Dx()/dstW
|
||||
srcY := base.Bounds().Min.Y + y*base.Bounds().Dy()/dstH
|
||||
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, base.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
if overlay != nil {
|
||||
for y := 0; y < dstH; y++ {
|
||||
for x := 0; x < dstW; x++ {
|
||||
srcX := overlay.Bounds().Min.X + x*overlay.Bounds().Dx()/dstW
|
||||
srcY := overlay.Bounds().Min.Y + y*overlay.Bounds().Dy()/dstH
|
||||
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlay.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeCrop 安全裁剪(超界返回nil)
|
||||
func safeCrop(img image.Image, rect image.Rectangle) image.Image {
|
||||
b := img.Bounds()
|
||||
if rect.Min.X < 0 || rect.Min.Y < 0 || rect.Max.X > b.Max.X || rect.Max.Y > b.Max.Y {
|
||||
return nil
|
||||
}
|
||||
subImg := image.NewRGBA(rect)
|
||||
draw.Draw(subImg, rect, img, rect.Min, draw.Src)
|
||||
return subImg
|
||||
}
|
||||
|
||||
// scaleNearest 最近邻缩放
|
||||
func scaleNearest(src image.Image, targetW, targetH int) *image.RGBA {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
srcBounds := src.Bounds()
|
||||
for y := 0; y < targetH; y++ {
|
||||
for x := 0; x < targetW; x++ {
|
||||
srcX := srcBounds.Min.X + x*srcBounds.Dx()/targetW
|
||||
srcY := srcBounds.Min.Y + y*srcBounds.Dy()/targetH
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// normalizeFormat 校验输出格式
|
||||
func normalizeFormat(format ImageFormat) (string, error) {
|
||||
if format == "" {
|
||||
format = ImageFormatPNG
|
||||
}
|
||||
switch format {
|
||||
case ImageFormatPNG:
|
||||
return "image/png", nil
|
||||
case ImageFormatWEBP:
|
||||
return "image/webp", nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的输出格式: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeImage 将图像编码为指定格式
|
||||
func encodeImage(img image.Image, format ImageFormat) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
switch format {
|
||||
case ImageFormatWEBP:
|
||||
if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -494,7 +494,8 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
// 在测试中传入nil作为storageClient,因为测试不涉及文件上传
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -531,13 +532,13 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "材质Hash已存在",
|
||||
name: "材质Hash已存在,应该成功创建并复用URL",
|
||||
uploaderID: 1,
|
||||
textureName: "DuplicateTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "existing-hash",
|
||||
wantErr: true,
|
||||
errContains: "已存在",
|
||||
wantErr: false, // 业务逻辑允许相同Hash存在,只是复用URL
|
||||
errContains: "",
|
||||
setupMocks: func() {
|
||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
||||
ID: 100,
|
||||
@@ -617,7 +618,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -675,7 +676,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -714,7 +715,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), texture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -764,7 +765,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -807,7 +808,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user