删除服务端材质渲染功能及system_config表,转为环境变量配置,初步配置管理员功能
This commit is contained in:
@@ -22,6 +22,7 @@ type Container struct {
|
||||
Redis *redis.Client
|
||||
Logger *zap.Logger
|
||||
JWT *auth.JWTService
|
||||
Casbin *auth.CasbinService
|
||||
Storage *storage.StorageClient
|
||||
CacheManager *database.CacheManager
|
||||
|
||||
@@ -31,20 +32,18 @@ type Container struct {
|
||||
TextureRepo repository.TextureRepository
|
||||
TokenRepo repository.TokenRepository
|
||||
ClientRepo repository.ClientRepository
|
||||
ConfigRepo repository.SystemConfigRepository
|
||||
YggdrasilRepo repository.YggdrasilRepository
|
||||
|
||||
// Service层
|
||||
UserService service.UserService
|
||||
ProfileService service.ProfileService
|
||||
TextureService service.TextureService
|
||||
TokenService service.TokenService
|
||||
YggdrasilService service.YggdrasilService
|
||||
VerificationService service.VerificationService
|
||||
SecurityService service.SecurityService
|
||||
CaptchaService service.CaptchaService
|
||||
SignatureService *service.SignatureService
|
||||
TextureRenderService service.TextureRenderService
|
||||
UserService service.UserService
|
||||
ProfileService service.ProfileService
|
||||
TextureService service.TextureService
|
||||
TokenService service.TokenService
|
||||
YggdrasilService service.YggdrasilService
|
||||
VerificationService service.VerificationService
|
||||
SecurityService service.SecurityService
|
||||
CaptchaService service.CaptchaService
|
||||
SignatureService *service.SignatureService
|
||||
}
|
||||
|
||||
// NewContainer 创建依赖容器
|
||||
@@ -53,6 +52,7 @@ func NewContainer(
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
jwtService *auth.JWTService,
|
||||
casbinService *auth.CasbinService,
|
||||
storageClient *storage.StorageClient,
|
||||
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
|
||||
) *Container {
|
||||
@@ -68,6 +68,7 @@ func NewContainer(
|
||||
Redis: redisClient,
|
||||
Logger: logger,
|
||||
JWT: jwtService,
|
||||
Casbin: casbinService,
|
||||
Storage: storageClient,
|
||||
CacheManager: cacheManager,
|
||||
}
|
||||
@@ -78,7 +79,6 @@ func NewContainer(
|
||||
c.TextureRepo = repository.NewTextureRepository(db)
|
||||
c.TokenRepo = repository.NewTokenRepository(db)
|
||||
c.ClientRepo = repository.NewClientRepository(db)
|
||||
c.ConfigRepo = repository.NewSystemConfigRepository(db)
|
||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||
|
||||
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
||||
@@ -86,10 +86,9 @@ func NewContainer(
|
||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||
|
||||
// 初始化Service(注入缓存管理器)
|
||||
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
||||
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
||||
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
|
||||
|
||||
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
||||
// 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT
|
||||
@@ -193,13 +192,6 @@ func WithTokenRepo(repo repository.TokenRepository) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigRepo 设置系统配置仓储
|
||||
func WithConfigRepo(repo repository.SystemConfigRepository) Option {
|
||||
return func(c *Container) {
|
||||
c.ConfigRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserService 设置用户服务
|
||||
func WithUserService(svc service.UserService) Option {
|
||||
return func(c *Container) {
|
||||
|
||||
366
internal/handler/admin_handler.go
Normal file
366
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminHandler 管理员处理器
|
||||
type AdminHandler struct {
|
||||
container *container.Container
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建管理员处理器
|
||||
func NewAdminHandler(c *container.Container) *AdminHandler {
|
||||
return &AdminHandler{container: c}
|
||||
}
|
||||
|
||||
// SetUserRoleRequest 设置用户角色请求
|
||||
type SetUserRoleRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Role string `json:"role" binding:"required,oneof=user admin"`
|
||||
}
|
||||
|
||||
// SetUserRole 设置用户角色
|
||||
// @Summary 设置用户角色
|
||||
// @Description 管理员设置指定用户的角色
|
||||
// @Tags Admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SetUserRoleRequest true "设置角色请求"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 400 {object} model.Response
|
||||
// @Failure 403 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/role [put]
|
||||
func (h *AdminHandler) SetUserRole(c *gin.Context) {
|
||||
var req SetUserRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前操作者ID
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 不能修改自己的角色
|
||||
if req.UserID == operatorID.(int64) {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的角色",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户角色
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"role": req.Role,
|
||||
})
|
||||
if err != nil {
|
||||
RespondServerError(c, "更新用户角色失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.container.Logger.Info("管理员修改用户角色",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.String("new_role", req.Role),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "用户角色更新成功",
|
||||
"user_id": req.UserID,
|
||||
"role": req.Role,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUserList 获取用户列表
|
||||
// @Summary 获取用户列表
|
||||
// @Description 管理员获取所有用户列表
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 403 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users [get]
|
||||
func (h *AdminHandler) GetUserList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用数据库直接查询用户列表
|
||||
var users []model.User
|
||||
var total int64
|
||||
|
||||
db := h.container.DB
|
||||
db.Model(&model.User{}).Count(&total)
|
||||
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
|
||||
|
||||
// 构建响应(隐藏敏感信息)
|
||||
userList := make([]gin.H, len(users))
|
||||
for i, u := range users {
|
||||
userList[i] = gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"avatar": u.Avatar,
|
||||
"role": u.Role,
|
||||
"status": u.Status,
|
||||
"points": u.Points,
|
||||
"last_login_at": u.LastLoginAt,
|
||||
"created_at": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"users": userList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUserDetail 获取用户详情
|
||||
// @Summary 获取用户详情
|
||||
// @Description 管理员获取指定用户的详细信息
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param id path int true "用户ID"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 404 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/{id} [get]
|
||||
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的用户ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"points": user.Points,
|
||||
"properties": user.Properties,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// SetUserStatusRequest 设置用户状态请求
|
||||
type SetUserStatusRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Status int16 `json:"status" binding:"required,oneof=1 0 -1"` // 1:正常, 0:禁用, -1:删除
|
||||
}
|
||||
|
||||
// SetUserStatus 设置用户状态
|
||||
// @Summary 设置用户状态
|
||||
// @Description 管理员设置用户状态(启用/禁用)
|
||||
// @Tags Admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SetUserStatusRequest true "设置状态请求"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 400 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/status [put]
|
||||
func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
var req SetUserStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 不能修改自己的状态
|
||||
if req.UserID == operatorID.(int64) {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的状态",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"status": req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
RespondServerError(c, "更新用户状态失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
|
||||
|
||||
h.container.Logger.Info("管理员修改用户状态",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.Int16("new_status", req.Status),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "用户状态更新成功",
|
||||
"user_id": req.UserID,
|
||||
"status": req.Status,
|
||||
"status_text": statusText,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteTexture 管理员删除材质
|
||||
// @Summary 管理员删除材质
|
||||
// @Description 管理员可以删除任意材质(用于审核不当内容)
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param id path int true "材质ID"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 404 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/textures/{id} [delete]
|
||||
func (h *AdminHandler) DeleteTexture(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 检查材质是否存在
|
||||
var texture model.Texture
|
||||
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"材质不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 删除材质
|
||||
if err := h.container.DB.Delete(&texture).Error; err != nil {
|
||||
RespondServerError(c, "删除材质失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.container.Logger.Info("管理员删除材质",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("texture_id", textureID),
|
||||
zap.Int64("uploader_id", texture.UploaderID),
|
||||
zap.String("texture_name", texture.Name),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "材质删除成功",
|
||||
"texture_id": textureID,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetTextureList 管理员获取材质列表
|
||||
// @Summary 管理员获取材质列表
|
||||
// @Description 管理员获取所有材质列表(用于审核)
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/textures [get]
|
||||
func (h *AdminHandler) GetTextureList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
var textures []model.Texture
|
||||
var total int64
|
||||
|
||||
db := h.container.DB
|
||||
db.Model(&model.Texture{}).Count(&total)
|
||||
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures)
|
||||
|
||||
// 构建响应
|
||||
textureList := make([]gin.H, len(textures))
|
||||
for i, t := range textures {
|
||||
uploaderName := ""
|
||||
if t.Uploader != nil {
|
||||
uploaderName = t.Uploader.Username
|
||||
}
|
||||
textureList[i] = gin.H{
|
||||
"id": t.ID,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"hash": t.Hash,
|
||||
"uploader_id": t.UploaderID,
|
||||
"uploader_name": uploaderName,
|
||||
"is_public": t.IsPublic,
|
||||
"download_count": t.DownloadCount,
|
||||
"created_at": t.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"textures": textureList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"carrotskin/internal/middleware"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
@@ -20,6 +21,7 @@ type Handlers struct {
|
||||
Captcha *CaptchaHandler
|
||||
Yggdrasil *YggdrasilHandler
|
||||
CustomSkin *CustomSkinHandler
|
||||
Admin *AdminHandler
|
||||
}
|
||||
|
||||
// NewHandlers 创建所有Handler实例
|
||||
@@ -32,6 +34,7 @@ func NewHandlers(c *container.Container) *Handlers {
|
||||
Captcha: NewCaptchaHandler(c),
|
||||
Yggdrasil: NewYggdrasilHandler(c),
|
||||
CustomSkin: NewCustomSkinHandler(c),
|
||||
Admin: NewAdminHandler(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +71,13 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
||||
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
|
||||
|
||||
// 系统路由
|
||||
registerSystemRoutes(v1)
|
||||
registerSystemRoutes(v1, c)
|
||||
|
||||
// CustomSkinAPI 路由
|
||||
registerCustomSkinRoutes(v1, h.CustomSkin)
|
||||
|
||||
// 管理员路由(需要管理员权限)
|
||||
registerAdminRoutes(v1, c, h.Admin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +119,6 @@ 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("")
|
||||
@@ -192,17 +194,46 @@ func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
|
||||
}
|
||||
|
||||
// registerSystemRoutes 注册系统路由
|
||||
func registerSystemRoutes(v1 *gin.RouterGroup) {
|
||||
func registerSystemRoutes(v1 *gin.RouterGroup, c *container.Container) {
|
||||
system := v1.Group("/system")
|
||||
{
|
||||
system.GET("/config", func(c *gin.Context) {
|
||||
// TODO: 实现从数据库读取系统配置
|
||||
c.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"site_name": "CarrotSkin",
|
||||
"site_description": "A Minecraft Skin Station",
|
||||
"registration_enabled": true,
|
||||
"max_textures_per_user": 100,
|
||||
"max_profiles_per_user": 5,
|
||||
// 公开配置(无需认证)
|
||||
system.GET("/config", func(ctx *gin.Context) {
|
||||
cfg, _ := config.GetConfig()
|
||||
ctx.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"site_name": cfg.Site.Name,
|
||||
"site_description": cfg.Site.Description,
|
||||
"registration_enabled": cfg.Site.RegistrationEnabled,
|
||||
"max_textures_per_user": cfg.Site.MaxTexturesPerUser,
|
||||
"max_profiles_per_user": cfg.Site.MaxProfilesPerUser,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// registerAdminRoutes 注册管理员路由
|
||||
func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) {
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware(c.JWT))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", h.GetUserList)
|
||||
admin.GET("/users/:id", h.GetUserDetail)
|
||||
admin.PUT("/users/role", h.SetUserRole)
|
||||
admin.PUT("/users/status", h.SetUserStatus)
|
||||
|
||||
// 材质管理(审核)
|
||||
admin.GET("/textures", h.GetTextureList)
|
||||
admin.DELETE("/textures/:id", h.DeleteTexture)
|
||||
|
||||
// 权限管理
|
||||
admin.GET("/permissions", func(ctx *gin.Context) {
|
||||
// 获取所有权限规则
|
||||
policies, _ := c.Casbin.GetEnforcer().GetPolicy()
|
||||
ctx.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"policies": policies,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"strconv"
|
||||
|
||||
@@ -85,98 +84,6 @@ 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)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
||||
// 将用户信息存储到上下文中
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("user_role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
||||
if err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("user_role", claims.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
internal/middleware/casbin.go
Normal file
109
internal/middleware/casbin.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CasbinMiddleware Casbin权限中间件
|
||||
// 需要先经过AuthMiddleware获取用户信息
|
||||
func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从上下文获取用户角色(由AuthMiddleware设置)
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr == "" {
|
||||
roleStr = "user" // 默认角色
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if !casbinService.CheckPermission(roleStr, resource, action) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 要求管理员权限的中间件
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "需要管理员权限",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 要求指定角色的中间件
|
||||
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否在允许的角色列表中
|
||||
for _, allowed := range allowedRoles {
|
||||
if roleStr == allowed {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型
|
||||
type ConfigType string
|
||||
|
||||
const (
|
||||
ConfigTypeString ConfigType = "STRING"
|
||||
ConfigTypeInteger ConfigType = "INTEGER"
|
||||
ConfigTypeBoolean ConfigType = "BOOLEAN"
|
||||
ConfigTypeJSON ConfigType = "JSON"
|
||||
)
|
||||
|
||||
// SystemConfig 系统配置模型
|
||||
type SystemConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Key string `gorm:"column:key;type:varchar(100);not null;uniqueIndex" json:"key"`
|
||||
Value string `gorm:"column:value;type:text;not null" json:"value"`
|
||||
Description string `gorm:"column:description;type:varchar(255);not null;default:''" json:"description"`
|
||||
Type ConfigType `gorm:"column:type;type:varchar(50);not null;default:'STRING'" json:"type"` // STRING, INTEGER, BOOLEAN, JSON
|
||||
IsPublic bool `gorm:"column:is_public;not null;default:false;index" json:"is_public"` // 是否可被前端获取
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SystemConfig) TableName() string {
|
||||
return "system_config"
|
||||
}
|
||||
|
||||
// SystemConfigPublicResponse 公开配置响应
|
||||
type SystemConfigPublicResponse struct {
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
Announcement string `json:"announcement"`
|
||||
}
|
||||
@@ -78,15 +78,6 @@ type TokenRepository interface {
|
||||
BatchDelete(ctx context.Context, accessTokens []string) (int64, error)
|
||||
}
|
||||
|
||||
// SystemConfigRepository 系统配置仓储接口
|
||||
type SystemConfigRepository interface {
|
||||
GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||
GetPublic(ctx context.Context) ([]model.SystemConfig, error)
|
||||
GetAll(ctx context.Context) ([]model.SystemConfig, error)
|
||||
Update(ctx context.Context, config *model.SystemConfig) error
|
||||
UpdateValue(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
// YggdrasilRepository Yggdrasil仓储接口
|
||||
type YggdrasilRepository interface {
|
||||
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// systemConfigRepository SystemConfigRepository的实现
|
||||
type systemConfigRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSystemConfigRepository 创建SystemConfigRepository实例
|
||||
func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository {
|
||||
return &systemConfigRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
|
||||
var config model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("key = ?", key).First(&config).Error
|
||||
return handleNotFoundResult(&config, err)
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var configs []model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var configs []model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error {
|
||||
return r.db.WithContext(ctx).Save(config).Error
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) UpdateValue(ctx context.Context, key, value string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSystemConfigRepository_QueryConditions 测试系统配置查询条件逻辑
|
||||
func TestSystemConfigRepository_QueryConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
isPublic bool
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的配置键",
|
||||
key: "site_name",
|
||||
isPublic: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "配置键为空",
|
||||
key: "",
|
||||
isPublic: true,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "公开配置查询",
|
||||
key: "site_name",
|
||||
isPublic: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "私有配置查询",
|
||||
key: "secret_key",
|
||||
isPublic: false,
|
||||
wantValid: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.key != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_PublicConfigLogic 测试公开配置逻辑
|
||||
func TestSystemConfigRepository_PublicConfigLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isPublic bool
|
||||
wantInclude bool
|
||||
}{
|
||||
{
|
||||
name: "只获取公开配置",
|
||||
isPublic: true,
|
||||
wantInclude: true,
|
||||
},
|
||||
{
|
||||
name: "私有配置不应包含",
|
||||
isPublic: false,
|
||||
wantInclude: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证逻辑:GetPublicSystemConfigs应该只返回is_public=true的配置
|
||||
if tt.isPublic != tt.wantInclude {
|
||||
t.Errorf("Public config logic failed: isPublic=%v, wantInclude=%v", tt.isPublic, tt.wantInclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_UpdateValueLogic 测试更新配置值逻辑
|
||||
func TestSystemConfigRepository_UpdateValueLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的键值对",
|
||||
key: "site_name",
|
||||
value: "CarrotSkin",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "键为空",
|
||||
key: "",
|
||||
value: "CarrotSkin",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "值为空(可能有效)",
|
||||
key: "site_name",
|
||||
value: "",
|
||||
wantValid: true, // 空值也可能是有效的
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.key != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Update value validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_ErrorHandling 测试错误处理逻辑
|
||||
func TestSystemConfigRepository_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isNotFound bool
|
||||
wantNilConfig bool
|
||||
}{
|
||||
{
|
||||
name: "记录未找到应该返回nil配置",
|
||||
isNotFound: true,
|
||||
wantNilConfig: true,
|
||||
},
|
||||
{
|
||||
name: "找到记录应该返回配置",
|
||||
isNotFound: false,
|
||||
wantNilConfig: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑:如果是RecordNotFound,返回nil配置
|
||||
if tt.isNotFound != tt.wantNilConfig {
|
||||
t.Errorf("Error handling logic failed: isNotFound=%v, wantNilConfig=%v", tt.isNotFound, tt.wantNilConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,69 +136,6 @@ 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
|
||||
|
||||
@@ -565,53 +565,6 @@ func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []st
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// MockSystemConfigRepository 模拟SystemConfigRepository
|
||||
type MockSystemConfigRepository struct {
|
||||
configs map[string]*model.SystemConfig
|
||||
}
|
||||
|
||||
func NewMockSystemConfigRepository() *MockSystemConfigRepository {
|
||||
return &MockSystemConfigRepository{
|
||||
configs: make(map[string]*model.SystemConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
|
||||
if config, ok := m.configs[key]; ok {
|
||||
return config, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var result []model.SystemConfig
|
||||
for _, v := range m.configs {
|
||||
result = append(result, *v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var result []model.SystemConfig
|
||||
for _, v := range m.configs {
|
||||
result = append(result, *v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error {
|
||||
m.configs[config.Key] = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) UpdateValue(ctx context.Context, key, value string) error {
|
||||
if config, ok := m.configs[key]; ok {
|
||||
config.Value = value
|
||||
return nil
|
||||
}
|
||||
return errors.New("config not found")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Mocks
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package skin_renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// CapeRenderer 披风渲染器
|
||||
type CapeRenderer struct{}
|
||||
|
||||
// NewCapeRenderer 创建披风渲染器
|
||||
func NewCapeRenderer() *CapeRenderer {
|
||||
return &CapeRenderer{}
|
||||
}
|
||||
|
||||
// Render 渲染披风
|
||||
// 披风纹理布局:
|
||||
// - 正面: (1, 1) 到 (11, 17) - 10x16 像素
|
||||
// - 背面: (12, 1) 到 (22, 17) - 10x16 像素
|
||||
func (r *CapeRenderer) Render(capeData []byte, height int) (image.Image, error) {
|
||||
// 解码披风图像
|
||||
img, err := png.Decode(bytes.NewReader(capeData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
srcWidth := bounds.Dx()
|
||||
srcHeight := bounds.Dy()
|
||||
|
||||
// 披风纹理可能是 64x32 或 22x17
|
||||
// 标准披风正面区域
|
||||
var frontX, frontY, frontW, frontH int
|
||||
|
||||
if srcWidth >= 64 && srcHeight >= 32 {
|
||||
// 64x32 格式(Minecraft 1.8+)
|
||||
// 正面: (1, 1) 到 (11, 17)
|
||||
frontX = 1
|
||||
frontY = 1
|
||||
frontW = 10
|
||||
frontH = 16
|
||||
} else if srcWidth >= 22 && srcHeight >= 17 {
|
||||
// 22x17 格式(旧版)
|
||||
frontX = 1
|
||||
frontY = 1
|
||||
frontW = 10
|
||||
frontH = 16
|
||||
} else {
|
||||
// 未知格式,直接缩放整个图像
|
||||
return resizeImageBilinear(img, height*srcWidth/srcHeight, height), nil
|
||||
}
|
||||
|
||||
// 提取正面区域
|
||||
front := image.NewRGBA(image.Rect(0, 0, frontW, frontH))
|
||||
for y := 0; y < frontH; y++ {
|
||||
for x := 0; x < frontW; x++ {
|
||||
front.Set(x, y, img.At(bounds.Min.X+frontX+x, bounds.Min.Y+frontY+y))
|
||||
}
|
||||
}
|
||||
|
||||
// 计算输出尺寸,保持宽高比
|
||||
outputWidth := height * frontW / frontH
|
||||
if outputWidth < 1 {
|
||||
outputWidth = 1
|
||||
}
|
||||
|
||||
// 使用最近邻缩放保持像素风格
|
||||
return scaleNearest(front, outputWidth, height), nil
|
||||
}
|
||||
|
||||
// scaleNearest 最近邻缩放
|
||||
func scaleNearest(src image.Image, width, height int) *image.RGBA {
|
||||
bounds := src.Bounds()
|
||||
srcW := bounds.Dx()
|
||||
srcH := bounds.Dy()
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
srcX := bounds.Min.X + x*srcW/width
|
||||
srcY := bounds.Min.Y + y*srcH/height
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// resizeImageBilinear 双线性插值缩放
|
||||
func resizeImageBilinear(src image.Image, width, height int) *image.RGBA {
|
||||
bounds := src.Bounds()
|
||||
srcW := float64(bounds.Dx())
|
||||
srcH := float64(bounds.Dy())
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
// 计算源图像中的位置
|
||||
srcX := float64(x) * srcW / float64(width)
|
||||
srcY := float64(y) * srcH / float64(height)
|
||||
|
||||
// 简单的最近邻(可以改进为双线性)
|
||||
ix := int(srcX)
|
||||
iy := int(srcY)
|
||||
|
||||
if ix >= bounds.Dx() {
|
||||
ix = bounds.Dx() - 1
|
||||
}
|
||||
if iy >= bounds.Dy() {
|
||||
iy = bounds.Dy() - 1
|
||||
}
|
||||
|
||||
dst.Set(x, y, src.At(bounds.Min.X+ix, bounds.Min.Y+iy))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package skin_renderer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
)
|
||||
|
||||
// Minecraft 提供 Minecraft 皮肤渲染的入口方法
|
||||
// 与 blessing/texture-renderer 的 Minecraft 类保持兼容
|
||||
type Minecraft struct{}
|
||||
|
||||
// NewMinecraft 创建 Minecraft 渲染器实例
|
||||
func NewMinecraft() *Minecraft {
|
||||
return &Minecraft{}
|
||||
}
|
||||
|
||||
// RenderSkin 渲染完整皮肤预览(正面+背面)
|
||||
// ratio: 缩放比例,默认 7.0
|
||||
// isAlex: 是否为 Alex 模型(细手臂)
|
||||
func (m *Minecraft) RenderSkin(skinData []byte, ratio float64, isAlex bool) (image.Image, error) {
|
||||
vp := 15 // vertical padding
|
||||
hp := 30 // horizontal padding
|
||||
ip := 15 // internal padding
|
||||
|
||||
// 渲染正面(-45度)
|
||||
frontRenderer := NewSkinRenderer(ratio, false, -45, -25)
|
||||
front, err := frontRenderer.Render(skinData, isAlex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 渲染背面(135度)
|
||||
backRenderer := NewSkinRenderer(ratio, false, 135, -25)
|
||||
back, err := backRenderer.Render(skinData, isAlex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := front.Bounds().Dx()
|
||||
height := front.Bounds().Dy()
|
||||
|
||||
// 创建画布
|
||||
canvas := createEmptyCanvas((hp+width+ip)*2, vp*2+height)
|
||||
|
||||
// 绘制背面(左侧)
|
||||
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+height), back, back.Bounds().Min, draw.Over)
|
||||
|
||||
// 绘制正面(右侧)
|
||||
draw.Draw(canvas, image.Rect(hp+width+ip*2, vp, hp+width*2+ip*2, vp+height), front, front.Bounds().Min, draw.Over)
|
||||
|
||||
return canvas, nil
|
||||
}
|
||||
|
||||
// RenderCape 渲染披风
|
||||
// height: 输出高度
|
||||
func (m *Minecraft) RenderCape(capeData []byte, height int) (image.Image, error) {
|
||||
vp := 20 // vertical padding
|
||||
hp := 40 // horizontal padding
|
||||
|
||||
renderer := NewCapeRenderer()
|
||||
cape, err := renderer.Render(capeData, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := cape.Bounds().Dx()
|
||||
capeHeight := cape.Bounds().Dy()
|
||||
|
||||
canvas := createEmptyCanvas(hp*2+width, vp*2+capeHeight)
|
||||
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+capeHeight), cape, cape.Bounds().Min, draw.Over)
|
||||
|
||||
return canvas, nil
|
||||
}
|
||||
|
||||
// Render2DAvatar 渲染 2D 头像
|
||||
// ratio: 缩放比例,默认 15.0
|
||||
func (m *Minecraft) Render2DAvatar(skinData []byte, ratio float64) (image.Image, error) {
|
||||
renderer := NewSkinRendererFull(ratio, true, 0, 0, 0, 0, 0, 0, 0, true)
|
||||
return renderer.Render(skinData, false)
|
||||
}
|
||||
|
||||
// Render3DAvatar 渲染 3D 头像
|
||||
// ratio: 缩放比例,默认 15.0
|
||||
func (m *Minecraft) Render3DAvatar(skinData []byte, ratio float64) (image.Image, error) {
|
||||
renderer := NewSkinRenderer(ratio, true, 45, -25)
|
||||
return renderer.Render(skinData, false)
|
||||
}
|
||||
|
||||
// RenderSkinWithAngle 渲染指定角度的皮肤
|
||||
func (m *Minecraft) RenderSkinWithAngle(skinData []byte, ratio float64, isAlex bool, hRotation, vRotation float64) (image.Image, error) {
|
||||
renderer := NewSkinRenderer(ratio, false, hRotation, vRotation)
|
||||
return renderer.Render(skinData, isAlex)
|
||||
}
|
||||
|
||||
// RenderHeadWithAngle 渲染指定角度的头像
|
||||
func (m *Minecraft) RenderHeadWithAngle(skinData []byte, ratio float64, hRotation, vRotation float64) (image.Image, error) {
|
||||
renderer := NewSkinRenderer(ratio, true, hRotation, vRotation)
|
||||
return renderer.Render(skinData, false)
|
||||
}
|
||||
|
||||
// createEmptyCanvas 创建透明画布
|
||||
func createEmptyCanvas(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
// 填充透明背景
|
||||
transparent := color.RGBA{0, 0, 0, 0}
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.SetRGBA(x, y, transparent)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Package skin_renderer 实现 Minecraft 皮肤的 3D 渲染
|
||||
// 移植自 blessing/texture-renderer
|
||||
package skin_renderer
|
||||
|
||||
// Point 表示 3D 空间中的一个点
|
||||
type Point struct {
|
||||
// 原始坐标
|
||||
originX, originY, originZ float64
|
||||
// 投影后的坐标
|
||||
destX, destY, destZ float64
|
||||
// 是否已投影
|
||||
isProjected bool
|
||||
isPreProjected bool
|
||||
}
|
||||
|
||||
// NewPoint 创建一个新的 3D 点
|
||||
func NewPoint(x, y, z float64) *Point {
|
||||
return &Point{
|
||||
originX: x,
|
||||
originY: y,
|
||||
originZ: z,
|
||||
}
|
||||
}
|
||||
|
||||
// Project 将 3D 点投影到 2D 平面
|
||||
// 使用欧拉角旋转:alpha 为垂直旋转(X轴),omega 为水平旋转(Y轴)
|
||||
func (p *Point) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
|
||||
x := p.originX
|
||||
y := p.originY
|
||||
z := p.originZ
|
||||
|
||||
// 3D 旋转投影公式
|
||||
p.destX = x*cosOmega + z*sinOmega
|
||||
p.destY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega
|
||||
p.destZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega
|
||||
|
||||
p.isProjected = true
|
||||
|
||||
// 更新边界
|
||||
if p.destX < *minX {
|
||||
*minX = p.destX
|
||||
}
|
||||
if p.destX > *maxX {
|
||||
*maxX = p.destX
|
||||
}
|
||||
if p.destY < *minY {
|
||||
*minY = p.destY
|
||||
}
|
||||
if p.destY > *maxY {
|
||||
*maxY = p.destY
|
||||
}
|
||||
}
|
||||
|
||||
// PreProject 预投影,用于部件独立旋转(如头部、手臂)
|
||||
// dx, dy, dz 为旋转中心点
|
||||
func (p *Point) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
|
||||
if p.isPreProjected {
|
||||
return
|
||||
}
|
||||
|
||||
// 相对于旋转中心的坐标
|
||||
x := p.originX - dx
|
||||
y := p.originY - dy
|
||||
z := p.originZ - dz
|
||||
|
||||
// 旋转后加回偏移
|
||||
p.originX = x*cosOmega + z*sinOmega + dx
|
||||
p.originY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega + dy
|
||||
p.originZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega + dz
|
||||
|
||||
p.isPreProjected = true
|
||||
}
|
||||
|
||||
// GetDestCoord 获取投影后的坐标
|
||||
func (p *Point) GetDestCoord() (x, y, z float64) {
|
||||
return p.destX, p.destY, p.destZ
|
||||
}
|
||||
|
||||
// GetOriginCoord 获取原始坐标
|
||||
func (p *Point) GetOriginCoord() (x, y, z float64) {
|
||||
return p.originX, p.originY, p.originZ
|
||||
}
|
||||
|
||||
// IsProjected 返回是否已投影
|
||||
func (p *Point) IsProjected() bool {
|
||||
return p.isProjected
|
||||
}
|
||||
|
||||
// GetDepth 获取深度值(用于排序)
|
||||
func (p *Point) GetDepth(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) float64 {
|
||||
if !p.isProjected {
|
||||
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
|
||||
}
|
||||
return p.destZ
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package skin_renderer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// Polygon 表示一个四边形面片
|
||||
type Polygon struct {
|
||||
dots [4]*Point
|
||||
color color.RGBA
|
||||
isProjected bool
|
||||
face string // 面的方向: "x", "y", "z"
|
||||
faceDepth float64 // 面的深度
|
||||
}
|
||||
|
||||
// NewPolygon 创建一个新的多边形
|
||||
func NewPolygon(dots [4]*Point, c color.RGBA) *Polygon {
|
||||
p := &Polygon{
|
||||
dots: dots,
|
||||
color: c,
|
||||
}
|
||||
|
||||
// 确定面的方向
|
||||
x0, y0, z0 := dots[0].GetOriginCoord()
|
||||
x1, y1, z1 := dots[1].GetOriginCoord()
|
||||
x2, y2, z2 := dots[2].GetOriginCoord()
|
||||
|
||||
if x0 == x1 && x1 == x2 {
|
||||
p.face = "x"
|
||||
p.faceDepth = x0
|
||||
} else if y0 == y1 && y1 == y2 {
|
||||
p.face = "y"
|
||||
p.faceDepth = y0
|
||||
} else if z0 == z1 && z1 == z2 {
|
||||
p.face = "z"
|
||||
p.faceDepth = z0
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Project 投影多边形的所有顶点
|
||||
func (p *Polygon) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
|
||||
for _, dot := range p.dots {
|
||||
if !dot.IsProjected() {
|
||||
dot.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
|
||||
}
|
||||
}
|
||||
p.isProjected = true
|
||||
}
|
||||
|
||||
// PreProject 预投影多边形的所有顶点
|
||||
func (p *Polygon) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
|
||||
for _, dot := range p.dots {
|
||||
dot.PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega)
|
||||
}
|
||||
}
|
||||
|
||||
// IsProjected 返回是否已投影
|
||||
func (p *Polygon) IsProjected() bool {
|
||||
return p.isProjected
|
||||
}
|
||||
|
||||
// AddToImage 将多边形绘制到图像上
|
||||
func (p *Polygon) AddToImage(img *image.RGBA, minX, minY, ratio float64) {
|
||||
// 检查透明度,完全透明则跳过
|
||||
if p.color.A == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取投影后的 2D 坐标
|
||||
points := make([][2]float64, 4)
|
||||
var coordX, coordY *float64
|
||||
|
||||
samePlanX := true
|
||||
samePlanY := true
|
||||
|
||||
for i, dot := range p.dots {
|
||||
x, y, _ := dot.GetDestCoord()
|
||||
points[i] = [2]float64{
|
||||
(x - minX) * ratio,
|
||||
(y - minY) * ratio,
|
||||
}
|
||||
|
||||
if coordX == nil {
|
||||
coordX = &x
|
||||
coordY = &y
|
||||
} else {
|
||||
if *coordX != x {
|
||||
samePlanX = false
|
||||
}
|
||||
if *coordY != y {
|
||||
samePlanY = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有点在同一平面(退化面),跳过
|
||||
if samePlanX || samePlanY {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用扫描线算法填充多边形
|
||||
fillPolygon(img, points, p.color)
|
||||
}
|
||||
|
||||
// fillPolygon 使用扫描线算法填充四边形
|
||||
func fillPolygon(img *image.RGBA, points [][2]float64, c color.RGBA) {
|
||||
// 找到 Y 的范围
|
||||
minY := points[0][1]
|
||||
maxY := points[0][1]
|
||||
for _, pt := range points {
|
||||
if pt[1] < minY {
|
||||
minY = pt[1]
|
||||
}
|
||||
if pt[1] > maxY {
|
||||
maxY = pt[1]
|
||||
}
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
||||
// 扫描每一行
|
||||
for y := int(minY); y <= int(maxY); y++ {
|
||||
if y < bounds.Min.Y || y >= bounds.Max.Y {
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到这一行与多边形边的交点
|
||||
var intersections []float64
|
||||
n := len(points)
|
||||
for i := 0; i < n; i++ {
|
||||
j := (i + 1) % n
|
||||
y1, y2 := points[i][1], points[j][1]
|
||||
x1, x2 := points[i][0], points[j][0]
|
||||
|
||||
// 检查这条边是否与当前扫描线相交
|
||||
if (y1 <= float64(y) && y2 > float64(y)) || (y2 <= float64(y) && y1 > float64(y)) {
|
||||
// 计算交点的 X 坐标
|
||||
t := (float64(y) - y1) / (y2 - y1)
|
||||
x := x1 + t*(x2-x1)
|
||||
intersections = append(intersections, x)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序交点
|
||||
for i := 0; i < len(intersections)-1; i++ {
|
||||
for j := i + 1; j < len(intersections); j++ {
|
||||
if intersections[i] > intersections[j] {
|
||||
intersections[i], intersections[j] = intersections[j], intersections[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 填充交点之间的像素
|
||||
for i := 0; i+1 < len(intersections); i += 2 {
|
||||
xStart := int(intersections[i])
|
||||
xEnd := int(intersections[i+1])
|
||||
|
||||
for x := xStart; x <= xEnd; x++ {
|
||||
if x >= bounds.Min.X && x < bounds.Max.X {
|
||||
// Alpha 混合
|
||||
if c.A == 255 {
|
||||
img.SetRGBA(x, y, c)
|
||||
} else {
|
||||
existing := img.RGBAAt(x, y)
|
||||
blended := alphaBlend(existing, c)
|
||||
img.SetRGBA(x, y, blended)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alphaBlend 执行 Alpha 混合
|
||||
func alphaBlend(dst, src color.RGBA) color.RGBA {
|
||||
if src.A == 0 {
|
||||
return dst
|
||||
}
|
||||
if src.A == 255 {
|
||||
return src
|
||||
}
|
||||
|
||||
srcA := float64(src.A) / 255.0
|
||||
dstA := float64(dst.A) / 255.0
|
||||
outA := srcA + dstA*(1-srcA)
|
||||
|
||||
if outA == 0 {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
return color.RGBA{
|
||||
R: uint8((float64(src.R)*srcA + float64(dst.R)*dstA*(1-srcA)) / outA),
|
||||
G: uint8((float64(src.G)*srcA + float64(dst.G)*dstA*(1-srcA)) / outA),
|
||||
B: uint8((float64(src.B)*srcA + float64(dst.B)*dstA*(1-srcA)) / outA),
|
||||
A: uint8(outA * 255),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,591 +0,0 @@
|
||||
package skin_renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
)
|
||||
|
||||
// SkinRenderer 皮肤渲染器
|
||||
type SkinRenderer struct {
|
||||
playerSkin image.Image
|
||||
isNewSkinType bool
|
||||
isAlex bool
|
||||
hdRatio int
|
||||
|
||||
// 旋转参数
|
||||
ratio float64
|
||||
headOnly bool
|
||||
hR float64 // 水平旋转角度
|
||||
vR float64 // 垂直旋转角度
|
||||
hrh float64 // 头部水平旋转
|
||||
vrll float64 // 左腿垂直旋转
|
||||
vrrl float64 // 右腿垂直旋转
|
||||
vrla float64 // 左臂垂直旋转
|
||||
vrra float64 // 右臂垂直旋转
|
||||
layers bool // 是否渲染第二层
|
||||
|
||||
// 计算后的三角函数值
|
||||
cosAlpha, sinAlpha float64
|
||||
cosOmega, sinOmega float64
|
||||
|
||||
// 边界
|
||||
minX, maxX, minY, maxY float64
|
||||
|
||||
// 各部件的旋转角度
|
||||
membersAngles map[string]angleSet
|
||||
|
||||
// 可见面
|
||||
visibleFaces map[string]faceVisibility
|
||||
frontFaces []string
|
||||
backFaces []string
|
||||
|
||||
// 多边形
|
||||
polygons map[string]map[string][]*Polygon
|
||||
}
|
||||
|
||||
type angleSet struct {
|
||||
cosAlpha, sinAlpha float64
|
||||
cosOmega, sinOmega float64
|
||||
}
|
||||
|
||||
type faceVisibility struct {
|
||||
front []string
|
||||
back []string
|
||||
}
|
||||
|
||||
var allFaces = []string{"back", "right", "top", "front", "left", "bottom"}
|
||||
|
||||
// NewSkinRenderer 创建皮肤渲染器
|
||||
func NewSkinRenderer(ratio float64, headOnly bool, horizontalRotation, verticalRotation float64) *SkinRenderer {
|
||||
return &SkinRenderer{
|
||||
ratio: ratio,
|
||||
headOnly: headOnly,
|
||||
hR: horizontalRotation,
|
||||
vR: verticalRotation,
|
||||
hrh: 0,
|
||||
vrll: 0,
|
||||
vrrl: 0,
|
||||
vrla: 0,
|
||||
vrra: 0,
|
||||
layers: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSkinRendererFull 创建带完整参数的皮肤渲染器
|
||||
func NewSkinRendererFull(ratio float64, headOnly bool, hR, vR, hrh, vrll, vrrl, vrla, vrra float64, layers bool) *SkinRenderer {
|
||||
return &SkinRenderer{
|
||||
ratio: ratio,
|
||||
headOnly: headOnly,
|
||||
hR: hR,
|
||||
vR: vR,
|
||||
hrh: hrh,
|
||||
vrll: vrll,
|
||||
vrrl: vrrl,
|
||||
vrla: vrla,
|
||||
vrra: vrra,
|
||||
layers: layers,
|
||||
}
|
||||
}
|
||||
|
||||
// Render 渲染皮肤
|
||||
func (r *SkinRenderer) Render(skinData []byte, isAlex bool) (image.Image, error) {
|
||||
// 解码皮肤图像
|
||||
img, err := png.Decode(bytes.NewReader(skinData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.playerSkin = img
|
||||
r.isAlex = isAlex
|
||||
|
||||
// 计算 HD 比例
|
||||
sourceWidth := img.Bounds().Dx()
|
||||
sourceHeight := img.Bounds().Dy()
|
||||
|
||||
// 防止内存溢出,限制最大尺寸
|
||||
if sourceWidth > 256 {
|
||||
r.playerSkin = resizeImage(img, 256, sourceHeight*256/sourceWidth)
|
||||
}
|
||||
|
||||
r.hdRatio = r.playerSkin.Bounds().Dx() / 64
|
||||
|
||||
// 检查是否为新版皮肤格式(64x64)
|
||||
if r.playerSkin.Bounds().Dx() == r.playerSkin.Bounds().Dy() {
|
||||
r.isNewSkinType = true
|
||||
}
|
||||
|
||||
// 转换为 RGBA
|
||||
r.playerSkin = convertToRGBA(r.playerSkin)
|
||||
|
||||
// 处理背景透明
|
||||
r.makeBackgroundTransparent()
|
||||
|
||||
// 计算角度
|
||||
r.calculateAngles()
|
||||
|
||||
// 确定可见面
|
||||
r.facesDetermination()
|
||||
|
||||
// 生成多边形
|
||||
r.generatePolygons()
|
||||
|
||||
// 部件旋转
|
||||
r.memberRotation()
|
||||
|
||||
// 创建投影
|
||||
r.createProjectionPlan()
|
||||
|
||||
// 渲染图像
|
||||
return r.displayImage(), nil
|
||||
}
|
||||
|
||||
// makeBackgroundTransparent 处理背景透明
|
||||
func (r *SkinRenderer) makeBackgroundTransparent() {
|
||||
rgba, ok := r.playerSkin.(*image.RGBA)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查左上角 8x8 区域是否为纯色
|
||||
var tempColor color.RGBA
|
||||
needRemove := true
|
||||
first := true
|
||||
|
||||
for y := 0; y < 8; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
c := rgba.RGBAAt(x, y)
|
||||
|
||||
// 如果已有透明度,不需要处理
|
||||
if c.A < 128 {
|
||||
needRemove = false
|
||||
break
|
||||
}
|
||||
|
||||
if first {
|
||||
tempColor = c
|
||||
first = false
|
||||
} else if c != tempColor {
|
||||
needRemove = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needRemove {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needRemove {
|
||||
return
|
||||
}
|
||||
|
||||
// 将该颜色设为透明
|
||||
bounds := rgba.Bounds()
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
c := rgba.RGBAAt(x, y)
|
||||
if c.R == tempColor.R && c.G == tempColor.G && c.B == tempColor.B {
|
||||
rgba.SetRGBA(x, y, color.RGBA{0, 0, 0, 0})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculateAngles 计算旋转角度
|
||||
func (r *SkinRenderer) calculateAngles() {
|
||||
// 转换为弧度
|
||||
alpha := r.vR * math.Pi / 180
|
||||
omega := r.hR * math.Pi / 180
|
||||
|
||||
r.cosAlpha = math.Cos(alpha)
|
||||
r.sinAlpha = math.Sin(alpha)
|
||||
r.cosOmega = math.Cos(omega)
|
||||
r.sinOmega = math.Sin(omega)
|
||||
|
||||
r.membersAngles = make(map[string]angleSet)
|
||||
|
||||
// 躯干不旋转
|
||||
r.membersAngles["torso"] = angleSet{
|
||||
cosAlpha: 1, sinAlpha: 0,
|
||||
cosOmega: 1, sinOmega: 0,
|
||||
}
|
||||
|
||||
// 头部旋转
|
||||
omegaHead := r.hrh * math.Pi / 180
|
||||
r.membersAngles["head"] = angleSet{
|
||||
cosAlpha: 1, sinAlpha: 0,
|
||||
cosOmega: math.Cos(omegaHead), sinOmega: math.Sin(omegaHead),
|
||||
}
|
||||
r.membersAngles["helmet"] = r.membersAngles["head"]
|
||||
|
||||
// 右臂旋转
|
||||
alphaRightArm := r.vrra * math.Pi / 180
|
||||
r.membersAngles["rightArm"] = angleSet{
|
||||
cosAlpha: math.Cos(alphaRightArm), sinAlpha: math.Sin(alphaRightArm),
|
||||
cosOmega: 1, sinOmega: 0,
|
||||
}
|
||||
|
||||
// 左臂旋转
|
||||
alphaLeftArm := r.vrla * math.Pi / 180
|
||||
r.membersAngles["leftArm"] = angleSet{
|
||||
cosAlpha: math.Cos(alphaLeftArm), sinAlpha: math.Sin(alphaLeftArm),
|
||||
cosOmega: 1, sinOmega: 0,
|
||||
}
|
||||
|
||||
// 右腿旋转
|
||||
alphaRightLeg := r.vrrl * math.Pi / 180
|
||||
r.membersAngles["rightLeg"] = angleSet{
|
||||
cosAlpha: math.Cos(alphaRightLeg), sinAlpha: math.Sin(alphaRightLeg),
|
||||
cosOmega: 1, sinOmega: 0,
|
||||
}
|
||||
|
||||
// 左腿旋转
|
||||
alphaLeftLeg := r.vrll * math.Pi / 180
|
||||
r.membersAngles["leftLeg"] = angleSet{
|
||||
cosAlpha: math.Cos(alphaLeftLeg), sinAlpha: math.Sin(alphaLeftLeg),
|
||||
cosOmega: 1, sinOmega: 0,
|
||||
}
|
||||
|
||||
r.minX, r.maxX = 0, 0
|
||||
r.minY, r.maxY = 0, 0
|
||||
}
|
||||
|
||||
// facesDetermination 确定可见面
|
||||
func (r *SkinRenderer) facesDetermination() {
|
||||
r.visibleFaces = make(map[string]faceVisibility)
|
||||
|
||||
parts := []string{"head", "torso", "rightArm", "leftArm", "rightLeg", "leftLeg"}
|
||||
|
||||
for _, part := range parts {
|
||||
angles := r.membersAngles[part]
|
||||
|
||||
// 创建测试立方体点
|
||||
cubePoints := r.createCubePoints()
|
||||
|
||||
var maxDepthPoint *Point
|
||||
var maxDepthFaces []string
|
||||
|
||||
for _, cp := range cubePoints {
|
||||
point := cp.point
|
||||
point.PreProject(0, 0, 0, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||
|
||||
if maxDepthPoint == nil {
|
||||
maxDepthPoint = point
|
||||
maxDepthFaces = cp.faces
|
||||
} else {
|
||||
_, _, z1 := maxDepthPoint.GetDestCoord()
|
||||
_, _, z2 := point.GetDestCoord()
|
||||
if z1 > z2 {
|
||||
maxDepthPoint = point
|
||||
maxDepthFaces = cp.faces
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.visibleFaces[part] = faceVisibility{
|
||||
back: maxDepthFaces,
|
||||
front: diffFaces(allFaces, maxDepthFaces),
|
||||
}
|
||||
}
|
||||
|
||||
// 确定全局前后面
|
||||
cubePoints := r.createCubePoints()
|
||||
var maxDepthPoint *Point
|
||||
var maxDepthFaces []string
|
||||
|
||||
for _, cp := range cubePoints {
|
||||
point := cp.point
|
||||
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||
|
||||
if maxDepthPoint == nil {
|
||||
maxDepthPoint = point
|
||||
maxDepthFaces = cp.faces
|
||||
} else {
|
||||
_, _, z1 := maxDepthPoint.GetDestCoord()
|
||||
_, _, z2 := point.GetDestCoord()
|
||||
if z1 > z2 {
|
||||
maxDepthPoint = point
|
||||
maxDepthFaces = cp.faces
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.backFaces = maxDepthFaces
|
||||
r.frontFaces = diffFaces(allFaces, maxDepthFaces)
|
||||
}
|
||||
|
||||
type cubePoint struct {
|
||||
point *Point
|
||||
faces []string
|
||||
}
|
||||
|
||||
func (r *SkinRenderer) createCubePoints() []cubePoint {
|
||||
return []cubePoint{
|
||||
{NewPoint(0, 0, 0), []string{"back", "right", "top"}},
|
||||
{NewPoint(0, 0, 1), []string{"front", "right", "top"}},
|
||||
{NewPoint(0, 1, 0), []string{"back", "right", "bottom"}},
|
||||
{NewPoint(0, 1, 1), []string{"front", "right", "bottom"}},
|
||||
{NewPoint(1, 0, 0), []string{"back", "left", "top"}},
|
||||
{NewPoint(1, 0, 1), []string{"front", "left", "top"}},
|
||||
{NewPoint(1, 1, 0), []string{"back", "left", "bottom"}},
|
||||
{NewPoint(1, 1, 1), []string{"front", "left", "bottom"}},
|
||||
}
|
||||
}
|
||||
|
||||
func diffFaces(all, exclude []string) []string {
|
||||
excludeMap := make(map[string]bool)
|
||||
for _, f := range exclude {
|
||||
excludeMap[f] = true
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, f := range all {
|
||||
if !excludeMap[f] {
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// memberRotation 部件旋转
|
||||
func (r *SkinRenderer) memberRotation() {
|
||||
hd := float64(r.hdRatio)
|
||||
|
||||
// 头部和头盔旋转
|
||||
angles := r.membersAngles["head"]
|
||||
for _, face := range r.polygons["head"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
for _, face := range r.polygons["helmet"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
|
||||
if r.headOnly {
|
||||
return
|
||||
}
|
||||
|
||||
// 右臂旋转
|
||||
angles = r.membersAngles["rightArm"]
|
||||
for _, face := range r.polygons["rightArm"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(-2*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
|
||||
// 左臂旋转
|
||||
angles = r.membersAngles["leftArm"]
|
||||
for _, face := range r.polygons["leftArm"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(10*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
|
||||
// 右腿旋转
|
||||
angles = r.membersAngles["rightLeg"]
|
||||
zOffset := 4 * hd
|
||||
if angles.sinAlpha < 0 {
|
||||
zOffset = 0
|
||||
}
|
||||
for _, face := range r.polygons["rightLeg"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(2*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
|
||||
// 左腿旋转
|
||||
angles = r.membersAngles["leftLeg"]
|
||||
zOffset = 4 * hd
|
||||
if angles.sinAlpha < 0 {
|
||||
zOffset = 0
|
||||
}
|
||||
for _, face := range r.polygons["leftLeg"] {
|
||||
for _, poly := range face {
|
||||
poly.PreProject(6*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createProjectionPlan 创建投影
|
||||
func (r *SkinRenderer) createProjectionPlan() {
|
||||
for _, piece := range r.polygons {
|
||||
for _, face := range piece {
|
||||
for _, poly := range face {
|
||||
if !poly.IsProjected() {
|
||||
poly.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// displayImage 渲染最终图像
|
||||
func (r *SkinRenderer) displayImage() image.Image {
|
||||
width := r.maxX - r.minX
|
||||
height := r.maxY - r.minY
|
||||
ratio := r.ratio * 2
|
||||
|
||||
srcWidth := int(ratio*width) + 1
|
||||
srcHeight := int(ratio*height) + 1
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, srcWidth, srcHeight))
|
||||
|
||||
// 按深度顺序绘制
|
||||
displayOrder := r.getDisplayOrder()
|
||||
|
||||
for _, order := range displayOrder {
|
||||
for piece, faces := range order {
|
||||
for _, face := range faces {
|
||||
if polys, ok := r.polygons[piece][face]; ok {
|
||||
for _, poly := range polys {
|
||||
poly.AddToImage(img, r.minX, r.minY, ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 抗锯齿:2x 渲染后缩小
|
||||
realWidth := srcWidth / 2
|
||||
realHeight := srcHeight / 2
|
||||
destImg := resizeImage(img, realWidth, realHeight)
|
||||
|
||||
return destImg
|
||||
}
|
||||
|
||||
// getDisplayOrder 获取绘制顺序
|
||||
func (r *SkinRenderer) getDisplayOrder() []map[string][]string {
|
||||
var displayOrder []map[string][]string
|
||||
|
||||
if contains(r.frontFaces, "top") {
|
||||
if contains(r.frontFaces, "right") {
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||
} else {
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||
}
|
||||
|
||||
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
|
||||
} else {
|
||||
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
|
||||
|
||||
if contains(r.frontFaces, "right") {
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||
} else {
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||
}
|
||||
}
|
||||
|
||||
return displayOrder
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func convertToRGBA(img image.Image) *image.RGBA {
|
||||
if rgba, ok := img.(*image.RGBA); ok {
|
||||
return rgba
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
rgba := image.NewRGBA(bounds)
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
rgba.Set(x, y, img.At(x, y))
|
||||
}
|
||||
}
|
||||
return rgba
|
||||
}
|
||||
|
||||
func resizeImage(img image.Image, width, height int) *image.RGBA {
|
||||
bounds := img.Bounds()
|
||||
srcW := bounds.Dx()
|
||||
srcH := bounds.Dy()
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
srcX := bounds.Min.X + x*srcW/width
|
||||
srcY := bounds.Min.Y + y*srcH/height
|
||||
dst.Set(x, y, img.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// getPixelColor 从皮肤图像获取像素颜色
|
||||
func (r *SkinRenderer) getPixelColor(x, y int) color.RGBA {
|
||||
if x < 0 || y < 0 {
|
||||
return color.RGBA{}
|
||||
}
|
||||
bounds := r.playerSkin.Bounds()
|
||||
if x >= bounds.Dx() || y >= bounds.Dy() {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
c := r.playerSkin.At(bounds.Min.X+x, bounds.Min.Y+y)
|
||||
r32, g32, b32, a32 := c.RGBA()
|
||||
return color.RGBA{
|
||||
R: uint8(r32 >> 8),
|
||||
G: uint8(g32 >> 8),
|
||||
B: uint8(b32 >> 8),
|
||||
A: uint8(a32 >> 8),
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package skin_renderer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createTestSkin 创建一个测试用的 64x64 皮肤图像
|
||||
func createTestSkin() []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
|
||||
// 填充一些测试颜色
|
||||
// 头部区域 (8,8) - (16,16)
|
||||
for y := 8; y < 16; y++ {
|
||||
for x := 8; x < 16; x++ {
|
||||
img.Set(x, y, image.White)
|
||||
}
|
||||
}
|
||||
|
||||
// 躯干区域 (20,20) - (28,32)
|
||||
for y := 20; y < 32; y++ {
|
||||
for x := 20; x < 28; x++ {
|
||||
img.Set(x, y, image.Black)
|
||||
}
|
||||
}
|
||||
|
||||
// 编码为 PNG
|
||||
f, _ := os.CreateTemp("", "test_skin_*.png")
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
png.Encode(f, img)
|
||||
f.Seek(0, 0)
|
||||
|
||||
data, _ := os.ReadFile(f.Name())
|
||||
return data
|
||||
}
|
||||
|
||||
func TestSkinRenderer_Render(t *testing.T) {
|
||||
skinData := createTestSkin()
|
||||
if len(skinData) == 0 {
|
||||
t.Skip("无法创建测试皮肤")
|
||||
}
|
||||
|
||||
renderer := NewSkinRenderer(7.0, false, -45, -25)
|
||||
result, err := renderer.Render(skinData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("渲染失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("渲染结果为空")
|
||||
}
|
||||
|
||||
bounds := result.Bounds()
|
||||
if bounds.Dx() == 0 || bounds.Dy() == 0 {
|
||||
t.Error("渲染结果尺寸为零")
|
||||
}
|
||||
|
||||
t.Logf("渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
func TestSkinRenderer_RenderHeadOnly(t *testing.T) {
|
||||
skinData := createTestSkin()
|
||||
if len(skinData) == 0 {
|
||||
t.Skip("无法创建测试皮肤")
|
||||
}
|
||||
|
||||
renderer := NewSkinRenderer(15.0, true, 45, -25)
|
||||
result, err := renderer.Render(skinData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("渲染头像失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("渲染结果为空")
|
||||
}
|
||||
|
||||
bounds := result.Bounds()
|
||||
t.Logf("头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
func TestMinecraft_RenderSkin(t *testing.T) {
|
||||
skinData := createTestSkin()
|
||||
if len(skinData) == 0 {
|
||||
t.Skip("无法创建测试皮肤")
|
||||
}
|
||||
|
||||
mc := NewMinecraft()
|
||||
result, err := mc.RenderSkin(skinData, 7.0, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderSkin 失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("渲染结果为空")
|
||||
}
|
||||
|
||||
bounds := result.Bounds()
|
||||
t.Logf("完整皮肤渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
func TestMinecraft_Render2DAvatar(t *testing.T) {
|
||||
skinData := createTestSkin()
|
||||
if len(skinData) == 0 {
|
||||
t.Skip("无法创建测试皮肤")
|
||||
}
|
||||
|
||||
mc := NewMinecraft()
|
||||
result, err := mc.Render2DAvatar(skinData, 15.0)
|
||||
if err != nil {
|
||||
t.Fatalf("Render2DAvatar 失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("渲染结果为空")
|
||||
}
|
||||
|
||||
bounds := result.Bounds()
|
||||
t.Logf("2D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
func TestMinecraft_Render3DAvatar(t *testing.T) {
|
||||
skinData := createTestSkin()
|
||||
if len(skinData) == 0 {
|
||||
t.Skip("无法创建测试皮肤")
|
||||
}
|
||||
|
||||
mc := NewMinecraft()
|
||||
result, err := mc.Render3DAvatar(skinData, 15.0)
|
||||
if err != nil {
|
||||
t.Fatalf("Render3DAvatar 失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("渲染结果为空")
|
||||
}
|
||||
|
||||
bounds := result.Bounds()
|
||||
t.Logf("3D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
func TestPoint_Project(t *testing.T) {
|
||||
p := NewPoint(1, 2, 3)
|
||||
|
||||
var minX, maxX, minY, maxY float64
|
||||
|
||||
// 测试 45 度旋转
|
||||
cosAlpha := 0.9063077870366499 // cos(-25°)
|
||||
sinAlpha := -0.42261826174069944 // sin(-25°)
|
||||
cosOmega := 0.7071067811865476 // cos(45°)
|
||||
sinOmega := 0.7071067811865476 // sin(45°)
|
||||
|
||||
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, &minX, &maxX, &minY, &maxY)
|
||||
|
||||
x, y, z := p.GetDestCoord()
|
||||
t.Logf("投影结果: x=%.2f, y=%.2f, z=%.2f", x, y, z)
|
||||
|
||||
if !p.IsProjected() {
|
||||
t.Error("点应该标记为已投影")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolygon_AddToImage(t *testing.T) {
|
||||
// 创建一个简单的正方形多边形
|
||||
p1 := NewPoint(0, 0, 0)
|
||||
p2 := NewPoint(10, 0, 0)
|
||||
p3 := NewPoint(10, 10, 0)
|
||||
p4 := NewPoint(0, 10, 0)
|
||||
|
||||
var minX, maxX, minY, maxY float64
|
||||
p1.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||
p2.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||
p3.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||
p4.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||
|
||||
poly := NewPolygon([4]*Point{p1, p2, p3, p4}, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
poly.AddToImage(img, minX, minY, 5.0)
|
||||
|
||||
// 检查是否有像素被绘制
|
||||
hasPixels := false
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
c := img.RGBAAt(x, y)
|
||||
if c.A > 0 {
|
||||
hasPixels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasPixels {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasPixels {
|
||||
t.Error("多边形应该在图像上绘制了像素")
|
||||
}
|
||||
}
|
||||
@@ -1,808 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/internal/service/skin_renderer"
|
||||
"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
|
||||
minecraft *skin_renderer.Minecraft // 3D 渲染器
|
||||
}
|
||||
|
||||
// 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,
|
||||
minecraft: skin_renderer.NewMinecraft(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 使用新的 3D 渲染器
|
||||
var rendered image.Image
|
||||
switch mode {
|
||||
case AvatarMode3D:
|
||||
// 使用 Blessing Skin 风格的 3D 头像渲染
|
||||
ratio := float64(size) / 15.0 // 基准比例
|
||||
rendered, err = s.minecraft.Render3DAvatar(textureData, ratio)
|
||||
if err != nil {
|
||||
s.logger.Warn("3D头像渲染失败,回退到2D", zap.Error(err))
|
||||
img, decErr := png.Decode(bytes.NewReader(textureData))
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("解码PNG失败: %w", decErr)
|
||||
}
|
||||
rendered = s.renderHeadView(img, size)
|
||||
}
|
||||
default:
|
||||
// 2D 头像使用新渲染器
|
||||
ratio := float64(size) / 15.0
|
||||
rendered, err = s.minecraft.Render2DAvatar(textureData, ratio)
|
||||
if err != nil {
|
||||
s.logger.Warn("2D头像渲染失败,回退到旧方法", zap.Error(err))
|
||||
img, decErr := png.Decode(bytes.NewReader(textureData))
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("解码PNG失败: %w", decErr)
|
||||
}
|
||||
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 渲染等距视图(使用 Blessing Skin 风格的真 3D 渲染)
|
||||
func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image {
|
||||
// 将图像编码为 PNG 数据
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
// 编码失败,回退到简单渲染
|
||||
return s.renderIsometricViewFallback(img, isSlim, size)
|
||||
}
|
||||
|
||||
// 使用新的 3D 渲染器渲染完整皮肤
|
||||
ratio := float64(size) / 32.0 // 基准比例,32 像素高度的皮肤
|
||||
rendered, err := s.minecraft.RenderSkin(buf.Bytes(), ratio, isSlim)
|
||||
if err != nil {
|
||||
// 渲染失败,回退到简单渲染
|
||||
return s.renderIsometricViewFallback(img, isSlim, size)
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// renderIsometricViewFallback 等距视图回退方案(简单 2D)
|
||||
func (s *textureRenderService) renderIsometricViewFallback(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 {
|
||||
// 将图像编码为 PNG 数据
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
// 编码失败,回退到简单缩放
|
||||
srcBounds := img.Bounds()
|
||||
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
|
||||
return img
|
||||
}
|
||||
return scaleNearest(img, size*2, size)
|
||||
}
|
||||
|
||||
// 使用新的披风渲染器
|
||||
rendered, err := s.minecraft.RenderCape(buf.Bytes(), size)
|
||||
if err != nil {
|
||||
// 渲染失败,回退到简单缩放
|
||||
srcBounds := img.Bounds()
|
||||
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
|
||||
return img
|
||||
}
|
||||
return scaleNearest(img, size*2, size)
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// 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 绘制单个分块(基础层+第二层,正确的 Alpha 混合)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制第二层(使用 Alpha 混合)
|
||||
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
|
||||
overlayColor := overlay.At(srcX, srcY)
|
||||
|
||||
// 获取 overlay 的 alpha 值
|
||||
_, _, _, a := overlayColor.RGBA()
|
||||
if a == 0 {
|
||||
// 完全透明,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
if a == 0xFFFF {
|
||||
// 完全不透明,直接覆盖
|
||||
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlayColor)
|
||||
} else {
|
||||
// 半透明,进行 Alpha 混合
|
||||
baseColor := dst.At(dstRect.Min.X+x, dstRect.Min.Y+y)
|
||||
blended := alphaBlendColors(baseColor, overlayColor)
|
||||
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, blended)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alphaBlendColors 执行 Alpha 混合
|
||||
func alphaBlendColors(dst, src color.Color) color.Color {
|
||||
sr, sg, sb, sa := src.RGBA()
|
||||
dr, dg, db, da := dst.RGBA()
|
||||
|
||||
if sa == 0 {
|
||||
return dst
|
||||
}
|
||||
if sa == 0xFFFF {
|
||||
return src
|
||||
}
|
||||
|
||||
// Alpha 混合公式
|
||||
srcA := float64(sa) / 0xFFFF
|
||||
dstA := float64(da) / 0xFFFF
|
||||
outA := srcA + dstA*(1-srcA)
|
||||
|
||||
if outA == 0 {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
outR := (float64(sr)*srcA + float64(dr)*dstA*(1-srcA)) / outA
|
||||
outG := (float64(sg)*srcA + float64(dg)*dstA*(1-srcA)) / outA
|
||||
outB := (float64(sb)*srcA + float64(db)*dstA*(1-srcA)) / outA
|
||||
|
||||
return color.RGBA64{
|
||||
R: uint16(outR),
|
||||
G: uint16(outG),
|
||||
B: uint16(outB),
|
||||
A: uint16(outA * 0xFFFF),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
// userService UserService的实现
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
configRepo repository.SystemConfigRepository
|
||||
jwtService *auth.JWTService
|
||||
redis *redis.Client
|
||||
cache *database.CacheManager
|
||||
@@ -40,7 +39,6 @@ type userService struct {
|
||||
// NewUserService 创建UserService实例
|
||||
func NewUserService(
|
||||
userRepo repository.UserRepository,
|
||||
configRepo repository.SystemConfigRepository,
|
||||
jwtService *auth.JWTService,
|
||||
redisClient *redis.Client,
|
||||
cacheManager *database.CacheManager,
|
||||
@@ -51,7 +49,6 @@ func NewUserService(
|
||||
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
|
||||
return &userService{
|
||||
userRepo: userRepo,
|
||||
configRepo: configRepo,
|
||||
jwtService: jwtService,
|
||||
redis: redisClient,
|
||||
cache: cacheManager,
|
||||
@@ -417,39 +414,29 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxProfilesPerUser() int {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
||||
if err != nil || config == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
|
||||
return 5
|
||||
}
|
||||
var value int
|
||||
fmt.Sscanf(config.Value, "%d", &value)
|
||||
if value <= 0 {
|
||||
return 5
|
||||
}
|
||||
return value
|
||||
return cfg.Site.MaxProfilesPerUser
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxTexturesPerUser() int {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "max_textures_per_user")
|
||||
if err != nil || config == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
|
||||
return 50
|
||||
}
|
||||
var value int
|
||||
fmt.Sscanf(config.Value, "%d", &value)
|
||||
if value <= 0 {
|
||||
return 50
|
||||
}
|
||||
return value
|
||||
return cfg.Site.MaxTexturesPerUser
|
||||
}
|
||||
|
||||
// 私有辅助方法
|
||||
|
||||
func (s *userService) getDefaultAvatar() string {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "default_avatar")
|
||||
if err != nil || config == nil || config.Value == "" {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return config.Value
|
||||
return cfg.Site.DefaultAvatar
|
||||
}
|
||||
|
||||
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
|
||||
|
||||
@@ -12,14 +12,13 @@ import (
|
||||
func TestUserServiceImpl_Register(t *testing.T) {
|
||||
// 准备依赖
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 初始化Service
|
||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
||||
// 注意:redisClient 和 storageClient 传入 nil,因为 Register 方法中没有使用它们
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -114,7 +113,6 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
||||
func TestUserServiceImpl_Login(t *testing.T) {
|
||||
// 准备依赖
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -130,7 +128,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -197,7 +195,6 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
|
||||
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -211,7 +208,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -246,7 +243,6 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
// TestUserServiceImpl_ChangePassword 测试 ChangePassword
|
||||
func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -259,7 +255,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -282,7 +278,6 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
// TestUserServiceImpl_ResetPassword 测试 ResetPassword
|
||||
func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -294,7 +289,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -312,7 +307,6 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
|
||||
func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -322,7 +316,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user2)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -340,12 +334,11 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
|
||||
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -373,30 +366,19 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
|
||||
// 现在配置从环境变量读取,测试默认值
|
||||
func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 未配置时走默认值
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
if got := userService.GetMaxTexturesPerUser(); got != 50 {
|
||||
t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
|
||||
// 配置有效值
|
||||
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_profiles_per_user", Value: "10"})
|
||||
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_textures_per_user", Value: "100"})
|
||||
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 10 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 配置值错误, got=%d", got)
|
||||
}
|
||||
if got := userService.GetMaxTexturesPerUser(); got != 100 {
|
||||
t.Fatalf("GetMaxTexturesPerUser 配置值错误, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +173,3 @@ type SystemConfigResponse struct {
|
||||
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
||||
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
|
||||
}
|
||||
|
||||
// RenderResponse 材质渲染响应
|
||||
type RenderResponse struct {
|
||||
URL string `json:"url" example:"https://rustfs.example.com/renders/xxx.png"`
|
||||
ContentType string `json:"content_type" example:"image/png"`
|
||||
ETag string `json:"etag,omitempty" example:"abc123def456"`
|
||||
Size int64 `json:"size" example:"2048"`
|
||||
LastModified *time.Time `json:"last_modified,omitempty" example:"2025-10-01T12:00:00Z"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user