chore: 初始化仓库,排除二进制文件和覆盖率文件
This commit is contained in:
249
internal/handler/auth_handler.go
Normal file
249
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/email"
|
||||
"carrotskin/pkg/logger"
|
||||
"carrotskin/pkg/redis"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Register 用户注册
|
||||
// @Summary 用户注册
|
||||
// @Description 注册新用户账号
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body types.RegisterRequest true "注册信息"
|
||||
// @Success 200 {object} model.Response "注册成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
jwtService := auth.MustGetJWTService()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
var req types.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
if err := service.VerifyCode(c.Request.Context(), redisClient, req.Email, req.VerificationCode, service.VerificationTypeRegister); err != nil {
|
||||
loggerInstance.Warn("验证码验证失败",
|
||||
zap.String("email", req.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用service层注册用户(传递可选的头像URL)
|
||||
user, token, err := service.RegisterUser(jwtService, req.Username, req.Password, req.Email, req.Avatar)
|
||||
if err != nil {
|
||||
loggerInstance.Error("用户注册失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.LoginResponse{
|
||||
Token: token,
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Points: user.Points,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// @Summary 用户登录
|
||||
// @Description 用户登录获取JWT Token,支持用户名或邮箱登录
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body types.LoginRequest true "登录信息(username字段支持用户名或邮箱)"
|
||||
// @Success 200 {object} model.Response{data=types.LoginResponse} "登录成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} model.ErrorResponse "登录失败"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
jwtService := auth.MustGetJWTService()
|
||||
|
||||
var req types.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取IP和UserAgent
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// 调用service层登录
|
||||
user, token, err := service.LoginUser(jwtService, req.Username, req.Password, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
loggerInstance.Warn("用户登录失败",
|
||||
zap.String("username_or_email", req.Username),
|
||||
zap.String("ip", ipAddress),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.LoginResponse{
|
||||
Token: token,
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Points: user.Points,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码
|
||||
// @Summary 发送验证码
|
||||
// @Description 发送邮箱验证码(注册/重置密码/更换邮箱)
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body types.SendVerificationCodeRequest true "发送验证码请求"
|
||||
// @Success 200 {object} model.Response "发送成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/auth/send-code [post]
|
||||
func SendVerificationCode(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
redisClient := redis.MustGetClient()
|
||||
emailService := email.MustGetService()
|
||||
|
||||
var req types.SendVerificationCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
if err := service.SendVerificationCode(c.Request.Context(), redisClient, emailService, req.Email, req.Type); err != nil {
|
||||
loggerInstance.Error("发送验证码失败",
|
||||
zap.String("email", req.Email),
|
||||
zap.String("type", req.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "验证码已发送,请查收邮件",
|
||||
}))
|
||||
}
|
||||
|
||||
// ResetPassword 重置密码
|
||||
// @Summary 重置密码
|
||||
// @Description 通过邮箱验证码重置密码
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body types.ResetPasswordRequest true "重置密码请求"
|
||||
// @Success 200 {object} model.Response "重置成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/auth/reset-password [post]
|
||||
func ResetPassword(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
var req types.ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
if err := service.VerifyCode(c.Request.Context(), redisClient, req.Email, req.VerificationCode, service.VerificationTypeResetPassword); err != nil {
|
||||
loggerInstance.Warn("验证码验证失败",
|
||||
zap.String("email", req.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
if err := service.ResetUserPassword(req.Email, req.NewPassword); err != nil {
|
||||
loggerInstance.Error("重置密码失败",
|
||||
zap.String("email", req.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "密码重置成功",
|
||||
}))
|
||||
}
|
||||
155
internal/handler/auth_handler_test.go
Normal file
155
internal/handler/auth_handler_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthHandler_RequestValidation 测试认证请求验证逻辑
|
||||
func TestAuthHandler_RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
email string
|
||||
password string
|
||||
code string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的注册请求",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
code: "123456",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "有效的登录请求",
|
||||
username: "testuser",
|
||||
email: "",
|
||||
password: "password123",
|
||||
code: "",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "用户名为空",
|
||||
username: "",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
code: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "密码为空",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
password: "",
|
||||
code: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "注册时验证码为空",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
code: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证请求参数逻辑
|
||||
isValid := tt.username != "" && tt.password != ""
|
||||
// 如果是注册请求,还需要验证码
|
||||
if tt.email != "" && tt.code == "" {
|
||||
isValid = false
|
||||
}
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Request validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_ErrorHandling 测试错误处理逻辑
|
||||
func TestAuthHandler_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType string
|
||||
wantCode int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "参数错误",
|
||||
errType: "binding",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "验证码错误",
|
||||
errType: "verification",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "登录失败",
|
||||
errType: "login",
|
||||
wantCode: 401,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "注册失败",
|
||||
errType: "register",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑
|
||||
if !tt.wantError {
|
||||
t.Error("Error handling test should expect error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_ResponseFormat 测试响应格式逻辑
|
||||
func TestAuthHandler_ResponseFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
success bool
|
||||
wantCode int
|
||||
hasToken bool
|
||||
}{
|
||||
{
|
||||
name: "注册成功",
|
||||
success: true,
|
||||
wantCode: 200,
|
||||
hasToken: true,
|
||||
},
|
||||
{
|
||||
name: "登录成功",
|
||||
success: true,
|
||||
wantCode: 200,
|
||||
hasToken: true,
|
||||
},
|
||||
{
|
||||
name: "发送验证码成功",
|
||||
success: true,
|
||||
wantCode: 200,
|
||||
hasToken: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证响应格式逻辑
|
||||
if tt.success && tt.wantCode != 200 {
|
||||
t.Errorf("Success response should have code 200, got %d", tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
76
internal/handler/captcha_handler.go
Normal file
76
internal/handler/captcha_handler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/pkg/redis"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Generate 生成验证码
|
||||
func Generate(c *gin.Context) {
|
||||
// 调用验证码服务生成验证码数据
|
||||
redisClient := redis.MustGetClient()
|
||||
masterImg, tileImg, captchaID, y, err := service.GenerateCaptchaData(c.Request.Context(), redisClient)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "生成验证码失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回验证码数据给前端
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"masterImage": masterImg, // 主图(base64格式)
|
||||
"tileImage": tileImg, // 滑块图(base64格式)
|
||||
"captchaId": captchaID, // 验证码唯一标识(用于后续验证)
|
||||
"y": y, // 滑块Y坐标(前端可用于定位滑块初始位置)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Verify 验证验证码
|
||||
func Verify(c *gin.Context) {
|
||||
// 定义请求参数结构体
|
||||
var req struct {
|
||||
CaptchaID string `json:"captchaId" binding:"required"` // 验证码唯一标识
|
||||
Dx int `json:"dx" binding:"required"` // 用户滑动的X轴偏移量
|
||||
}
|
||||
|
||||
// 解析并校验请求参数
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用验证码服务验证偏移量
|
||||
redisClient := redis.MustGetClient()
|
||||
valid, err := service.VerifyCaptchaData(c.Request.Context(), redisClient, req.Dx, req.CaptchaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "验证失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据验证结果返回响应
|
||||
if valid {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "验证成功",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "验证失败,请重试",
|
||||
})
|
||||
}
|
||||
}
|
||||
133
internal/handler/captcha_handler_test.go
Normal file
133
internal/handler/captcha_handler_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCaptchaHandler_RequestValidation 测试验证码请求验证逻辑
|
||||
func TestCaptchaHandler_RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
captchaID string
|
||||
dx int
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的请求参数",
|
||||
captchaID: "captcha-123",
|
||||
dx: 100,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "captchaID为空",
|
||||
captchaID: "",
|
||||
dx: 100,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "dx为0(可能有效)",
|
||||
captchaID: "captcha-123",
|
||||
dx: 0,
|
||||
wantValid: true, // dx为0也可能是有效的(用户没有滑动)
|
||||
},
|
||||
{
|
||||
name: "dx为负数(可能无效)",
|
||||
captchaID: "captcha-123",
|
||||
dx: -10,
|
||||
wantValid: true, // 负数也可能是有效的,取决于业务逻辑
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.captchaID != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Request validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptchaHandler_ResponseFormat 测试响应格式逻辑
|
||||
func TestCaptchaHandler_ResponseFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
wantCode int
|
||||
wantStatus string
|
||||
}{
|
||||
{
|
||||
name: "验证成功",
|
||||
valid: true,
|
||||
wantCode: 200,
|
||||
wantStatus: "验证成功",
|
||||
},
|
||||
{
|
||||
name: "验证失败",
|
||||
valid: false,
|
||||
wantCode: 400,
|
||||
wantStatus: "验证失败,请重试",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证响应格式逻辑
|
||||
var code int
|
||||
var status string
|
||||
if tt.valid {
|
||||
code = 200
|
||||
status = "验证成功"
|
||||
} else {
|
||||
code = 400
|
||||
status = "验证失败,请重试"
|
||||
}
|
||||
|
||||
if code != tt.wantCode {
|
||||
t.Errorf("Response code = %d, want %d", code, tt.wantCode)
|
||||
}
|
||||
if status != tt.wantStatus {
|
||||
t.Errorf("Response status = %q, want %q", status, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptchaHandler_ErrorHandling 测试错误处理逻辑
|
||||
func TestCaptchaHandler_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hasError bool
|
||||
wantCode int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "生成验证码失败",
|
||||
hasError: true,
|
||||
wantCode: 500,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "验证验证码失败",
|
||||
hasError: true,
|
||||
wantCode: 500,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "参数错误",
|
||||
hasError: true,
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑
|
||||
if tt.hasError && !tt.wantError {
|
||||
t.Error("Error handling logic failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
398
internal/handler/profile_handler.go
Normal file
398
internal/handler/profile_handler.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/logger"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreateProfile 创建档案
|
||||
// @Summary 创建Minecraft档案
|
||||
// @Description 创建新的Minecraft角色档案,UUID由后端自动生成
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.CreateProfileRequest true "档案信息(仅需提供角色名)"
|
||||
// @Success 200 {object} model.Response{data=types.ProfileInfo} "创建成功,返回完整档案信息(含自动生成的UUID)"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误或已达档案数量上限"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile [post]
|
||||
func CreateProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
// 获取用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
"未授权",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req types.CreateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误: "+err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 从配置或数据库读取限制
|
||||
maxProfiles := 5
|
||||
db := database.MustGetDB()
|
||||
// 检查档案数量限制
|
||||
if err := service.CheckProfileLimit(db, userID.(int64), maxProfiles); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建档案
|
||||
profile, err := service.CreateProfile(db, userID.(int64), req.Name)
|
||||
if err != nil {
|
||||
loggerInstance.Error("创建档案失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("name", req.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
|
||||
UUID: profile.UUID,
|
||||
UserID: profile.UserID,
|
||||
Name: profile.Name,
|
||||
SkinID: profile.SkinID,
|
||||
CapeID: profile.CapeID,
|
||||
IsActive: profile.IsActive,
|
||||
LastUsedAt: profile.LastUsedAt,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetProfiles 获取档案列表
|
||||
// @Summary 获取档案列表
|
||||
// @Description 获取当前用户的所有档案
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} model.Response "获取成功"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile [get]
|
||||
func GetProfiles(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
// 获取用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
"未授权",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 查询档案列表
|
||||
profiles, err := service.GetUserProfiles(database.MustGetDB(), userID.(int64))
|
||||
if err != nil {
|
||||
loggerInstance.Error("获取档案列表失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*types.ProfileInfo, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
result = append(result, &types.ProfileInfo{
|
||||
UUID: profile.UUID,
|
||||
UserID: profile.UserID,
|
||||
Name: profile.Name,
|
||||
SkinID: profile.SkinID,
|
||||
CapeID: profile.CapeID,
|
||||
IsActive: profile.IsActive,
|
||||
LastUsedAt: profile.LastUsedAt,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(result))
|
||||
}
|
||||
|
||||
// GetProfile 获取档案详情
|
||||
// @Summary 获取档案详情
|
||||
// @Description 根据UUID获取档案详细信息
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param uuid path string true "档案UUID"
|
||||
// @Success 200 {object} model.Response "获取成功"
|
||||
// @Failure 404 {object} model.ErrorResponse "档案不存在"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile/{uuid} [get]
|
||||
func GetProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// 查询档案
|
||||
profile, err := service.GetProfileByUUID(database.MustGetDB(), uuid)
|
||||
if err != nil {
|
||||
loggerInstance.Error("获取档案失败",
|
||||
zap.String("uuid", uuid),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
|
||||
UUID: profile.UUID,
|
||||
UserID: profile.UserID,
|
||||
Name: profile.Name,
|
||||
SkinID: profile.SkinID,
|
||||
CapeID: profile.CapeID,
|
||||
IsActive: profile.IsActive,
|
||||
LastUsedAt: profile.LastUsedAt,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// UpdateProfile 更新档案
|
||||
// @Summary 更新档案
|
||||
// @Description 更新档案信息
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param uuid path string true "档案UUID"
|
||||
// @Param request body types.UpdateProfileRequest true "更新信息"
|
||||
// @Success 200 {object} model.Response "更新成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Failure 404 {object} model.ErrorResponse "档案不存在"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile/{uuid} [put]
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// 获取用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
"未授权",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req types.UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误: "+err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新档案
|
||||
var namePtr *string
|
||||
if req.Name != "" {
|
||||
namePtr = &req.Name
|
||||
}
|
||||
|
||||
profile, err := service.UpdateProfile(database.MustGetDB(), uuid, userID.(int64), namePtr, req.SkinID, req.CapeID)
|
||||
if err != nil {
|
||||
loggerInstance.Error("更新档案失败",
|
||||
zap.String("uuid", uuid),
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
statusCode := http.StatusInternalServerError
|
||||
if err.Error() == "档案不存在" {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if err.Error() == "无权操作此档案" {
|
||||
statusCode = http.StatusForbidden
|
||||
}
|
||||
|
||||
c.JSON(statusCode, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
|
||||
UUID: profile.UUID,
|
||||
UserID: profile.UserID,
|
||||
Name: profile.Name,
|
||||
SkinID: profile.SkinID,
|
||||
CapeID: profile.CapeID,
|
||||
IsActive: profile.IsActive,
|
||||
LastUsedAt: profile.LastUsedAt,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteProfile 删除档案
|
||||
// @Summary 删除档案
|
||||
// @Description 删除指定的Minecraft档案
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param uuid path string true "档案UUID"
|
||||
// @Success 200 {object} model.Response "删除成功"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Failure 404 {object} model.ErrorResponse "档案不存在"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile/{uuid} [delete]
|
||||
func DeleteProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// 获取用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
"未授权",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 删除档案
|
||||
err := service.DeleteProfile(database.MustGetDB(), uuid, userID.(int64))
|
||||
if err != nil {
|
||||
loggerInstance.Error("删除档案失败",
|
||||
zap.String("uuid", uuid),
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
statusCode := http.StatusInternalServerError
|
||||
if err.Error() == "档案不存在" {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if err.Error() == "无权操作此档案" {
|
||||
statusCode = http.StatusForbidden
|
||||
}
|
||||
|
||||
c.JSON(statusCode, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "删除成功",
|
||||
}))
|
||||
}
|
||||
|
||||
// SetActiveProfile 设置活跃档案
|
||||
// @Summary 设置活跃档案
|
||||
// @Description 将指定档案设置为活跃状态
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param uuid path string true "档案UUID"
|
||||
// @Success 200 {object} model.Response "设置成功"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Failure 404 {object} model.ErrorResponse "档案不存在"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/profile/{uuid}/activate [post]
|
||||
func SetActiveProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// 获取用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
"未授权",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 设置活跃状态
|
||||
err := service.SetActiveProfile(database.MustGetDB(), uuid, userID.(int64))
|
||||
if err != nil {
|
||||
loggerInstance.Error("设置活跃档案失败",
|
||||
zap.String("uuid", uuid),
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
statusCode := http.StatusInternalServerError
|
||||
if err.Error() == "档案不存在" {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if err.Error() == "无权操作此档案" {
|
||||
statusCode = http.StatusForbidden
|
||||
}
|
||||
|
||||
c.JSON(statusCode, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "设置成功",
|
||||
}))
|
||||
}
|
||||
151
internal/handler/profile_handler_test.go
Normal file
151
internal/handler/profile_handler_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProfileHandler_PermissionCheck 测试权限检查逻辑
|
||||
func TestProfileHandler_PermissionCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID interface{}
|
||||
exists bool
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的用户ID",
|
||||
userID: int64(1),
|
||||
exists: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "用户ID不存在",
|
||||
userID: nil,
|
||||
exists: false,
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证权限检查逻辑
|
||||
isValid := tt.exists
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Permission check failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileHandler_RequestValidation 测试请求验证逻辑
|
||||
func TestProfileHandler_RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profileName string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的档案名",
|
||||
profileName: "PlayerName",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "档案名为空",
|
||||
profileName: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "档案名长度超过16",
|
||||
profileName: "ThisIsAVeryLongPlayerName",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证请求逻辑:档案名长度应该在1-16之间
|
||||
isValid := tt.profileName != "" && len(tt.profileName) >= 1 && len(tt.profileName) <= 16
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Request validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileHandler_LimitCheck 测试限制检查逻辑
|
||||
func TestProfileHandler_LimitCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentCount int
|
||||
maxCount int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "未达到限制",
|
||||
currentCount: 3,
|
||||
maxCount: 5,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "达到限制",
|
||||
currentCount: 5,
|
||||
maxCount: 5,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "超过限制",
|
||||
currentCount: 6,
|
||||
maxCount: 5,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证限制检查逻辑
|
||||
hasError := tt.currentCount >= tt.maxCount
|
||||
if hasError != tt.wantError {
|
||||
t.Errorf("Limit check failed: got error=%v, want error=%v", hasError, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileHandler_ErrorHandling 测试错误处理逻辑
|
||||
func TestProfileHandler_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType string
|
||||
wantCode int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "未授权",
|
||||
errType: "unauthorized",
|
||||
wantCode: 401,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "参数错误",
|
||||
errType: "bad_request",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "服务器错误",
|
||||
errType: "server_error",
|
||||
wantCode: 500,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑
|
||||
if !tt.wantError {
|
||||
t.Error("Error handling test should expect error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
139
internal/handler/routes.go
Normal file
139
internal/handler/routes.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/middleware"
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 注册所有路由
|
||||
func RegisterRoutes(router *gin.Engine) {
|
||||
// 设置Swagger文档
|
||||
SetupSwagger(router)
|
||||
|
||||
// API路由组
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// 认证路由(无需JWT)
|
||||
authGroup := v1.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/register", Register)
|
||||
authGroup.POST("/login", Login)
|
||||
authGroup.POST("/send-code", SendVerificationCode)
|
||||
authGroup.POST("/reset-password", ResetPassword)
|
||||
}
|
||||
|
||||
// 用户路由(需要JWT认证)
|
||||
userGroup := v1.Group("/user")
|
||||
userGroup.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
userGroup.GET("/profile", GetUserProfile)
|
||||
userGroup.PUT("/profile", UpdateUserProfile)
|
||||
|
||||
// 头像相关
|
||||
userGroup.POST("/avatar/upload-url", GenerateAvatarUploadURL)
|
||||
userGroup.PUT("/avatar", UpdateAvatar)
|
||||
|
||||
// 更换邮箱
|
||||
userGroup.POST("/change-email", ChangeEmail)
|
||||
}
|
||||
|
||||
// 材质路由
|
||||
textureGroup := v1.Group("/texture")
|
||||
{
|
||||
// 公开路由(无需认证)
|
||||
textureGroup.GET("", SearchTextures) // 搜索材质
|
||||
textureGroup.GET("/:id", GetTexture) // 获取材质详情
|
||||
|
||||
// 需要认证的路由
|
||||
textureAuth := textureGroup.Group("")
|
||||
textureAuth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
textureAuth.POST("/upload-url", GenerateTextureUploadURL) // 生成上传URL
|
||||
textureAuth.POST("", CreateTexture) // 创建材质记录
|
||||
textureAuth.PUT("/:id", UpdateTexture) // 更新材质
|
||||
textureAuth.DELETE("/:id", DeleteTexture) // 删除材质
|
||||
textureAuth.POST("/:id/favorite", ToggleFavorite) // 切换收藏
|
||||
textureAuth.GET("/my", GetUserTextures) // 我的材质
|
||||
textureAuth.GET("/favorites", GetUserFavorites) // 我的收藏
|
||||
}
|
||||
}
|
||||
|
||||
// 档案路由
|
||||
profileGroup := v1.Group("/profile")
|
||||
{
|
||||
// 公开路由(无需认证)
|
||||
profileGroup.GET("/:uuid", GetProfile) // 获取档案详情
|
||||
|
||||
// 需要认证的路由
|
||||
profileAuth := profileGroup.Group("")
|
||||
profileAuth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
profileAuth.POST("/", CreateProfile) // 创建档案
|
||||
profileAuth.GET("/", GetProfiles) // 获取我的档案列表
|
||||
profileAuth.PUT("/:uuid", UpdateProfile) // 更新档案
|
||||
profileAuth.DELETE("/:uuid", DeleteProfile) // 删除档案
|
||||
profileAuth.POST("/:uuid/activate", SetActiveProfile) // 设置活跃档案
|
||||
}
|
||||
}
|
||||
// 验证码路由
|
||||
captchaGroup := v1.Group("/captcha")
|
||||
{
|
||||
captchaGroup.GET("/generate", Generate) //生成验证码
|
||||
captchaGroup.POST("/verify", Verify) //验证验证码
|
||||
}
|
||||
|
||||
// Yggdrasil API路由组
|
||||
ygg := v1.Group("/yggdrasil")
|
||||
{
|
||||
ygg.GET("", GetMetaData)
|
||||
ygg.POST("/minecraftservices/player/certificates", GetPlayerCertificates)
|
||||
authserver := ygg.Group("/authserver")
|
||||
{
|
||||
authserver.POST("/authenticate", Authenticate)
|
||||
authserver.POST("/validate", ValidToken)
|
||||
authserver.POST("/refresh", RefreshToken)
|
||||
authserver.POST("/invalidate", InvalidToken)
|
||||
authserver.POST("/signout", SignOut)
|
||||
}
|
||||
sessionServer := ygg.Group("/sessionserver")
|
||||
{
|
||||
sessionServer.GET("/session/minecraft/profile/:uuid", GetProfileByUUID)
|
||||
sessionServer.POST("/session/minecraft/join", JoinServer)
|
||||
sessionServer.GET("/session/minecraft/hasJoined", HasJoinedServer)
|
||||
}
|
||||
api := ygg.Group("/api")
|
||||
profiles := api.Group("/profiles")
|
||||
{
|
||||
profiles.POST("/minecraft", GetProfilesByName)
|
||||
}
|
||||
}
|
||||
// 系统路由
|
||||
system := v1.Group("/system")
|
||||
{
|
||||
system.GET("/config", GetSystemConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 以下是系统配置相关的占位符函数,待后续实现
|
||||
|
||||
// GetSystemConfig 获取系统配置
|
||||
// @Summary 获取系统配置
|
||||
// @Description 获取公开的系统配置信息
|
||||
// @Tags system
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.Response "获取成功"
|
||||
// @Router /api/v1/system/config [get]
|
||||
func GetSystemConfig(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,
|
||||
}))
|
||||
}
|
||||
62
internal/handler/swagger.go
Normal file
62
internal/handler/swagger.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// @title CarrotSkin API
|
||||
// @version 1.0
|
||||
// @description CarrotSkin 是一个优秀的 Minecraft 皮肤站 API 服务
|
||||
// @description
|
||||
// @description ## 功能特性
|
||||
// @description - 用户注册/登录/管理
|
||||
// @description - 材质上传/下载/管理
|
||||
// @description - Minecraft 档案管理
|
||||
// @description - 权限控制系统
|
||||
// @description - 积分系统
|
||||
// @description
|
||||
// @description ## 认证方式
|
||||
// @description 使用 JWT Token 进行身份认证,需要在请求头中包含:
|
||||
// @description ```
|
||||
// @description Authorization: Bearer <your-jwt-token>
|
||||
// @description ```
|
||||
|
||||
// @contact.name CarrotSkin Team
|
||||
// @contact.email support@carrotskin.com
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
func SetupSwagger(router *gin.Engine) {
|
||||
// Swagger文档路由
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 健康检查接口
|
||||
router.GET("/health", HealthCheck)
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
// @Summary 健康检查
|
||||
// @Description 检查服务是否正常运行
|
||||
// @Tags system
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "成功"
|
||||
// @Router /health [get]
|
||||
func HealthCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"message": "CarrotSkin API is running",
|
||||
})
|
||||
}
|
||||
599
internal/handler/texture_handler.go
Normal file
599
internal/handler/texture_handler.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/logger"
|
||||
"carrotskin/pkg/storage"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GenerateTextureUploadURL 生成材质上传URL
|
||||
// @Summary 生成材质上传URL
|
||||
// @Description 生成预签名URL用于上传材质文件
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.GenerateTextureUploadURLRequest true "上传URL请求"
|
||||
// @Success 200 {object} model.Response "生成成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/texture/upload-url [post]
|
||||
func GenerateTextureUploadURL(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.GenerateTextureUploadURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用UploadService生成预签名URL
|
||||
storageClient := storage.MustGetClient()
|
||||
cfg := *config.MustGetRustFSConfig()
|
||||
result, err := service.GenerateTextureUploadURL(
|
||||
c.Request.Context(),
|
||||
storageClient,
|
||||
cfg,
|
||||
userID.(int64),
|
||||
req.FileName,
|
||||
string(req.TextureType),
|
||||
)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("生成材质上传URL失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("file_name", req.FileName),
|
||||
zap.String("texture_type", string(req.TextureType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateTextureUploadURLResponse{
|
||||
PostURL: result.PostURL,
|
||||
FormData: result.FormData,
|
||||
TextureURL: result.FileURL,
|
||||
ExpiresIn: 900, // 15分钟 = 900秒
|
||||
}))
|
||||
}
|
||||
|
||||
// CreateTexture 创建材质记录
|
||||
// @Summary 创建材质记录
|
||||
// @Description 文件上传完成后,创建材质记录到数据库
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.CreateTextureRequest true "创建材质请求"
|
||||
// @Success 200 {object} model.Response "创建成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/texture [post]
|
||||
func CreateTexture(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.CreateTextureRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 从配置或数据库读取限制
|
||||
maxTextures := 100
|
||||
if err := service.CheckTextureUploadLimit(database.MustGetDB(), userID.(int64), maxTextures); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建材质
|
||||
texture, err := service.CreateTexture(database.MustGetDB(),
|
||||
userID.(int64),
|
||||
req.Name,
|
||||
req.Description,
|
||||
string(req.Type),
|
||||
req.URL,
|
||||
req.Hash,
|
||||
req.Size,
|
||||
req.IsPublic,
|
||||
req.IsSlim,
|
||||
)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("创建材质失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("name", req.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetTexture 获取材质详情
|
||||
// @Summary 获取材质详情
|
||||
// @Description 根据ID获取材质详细信息
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "材质ID"
|
||||
// @Success 200 {object} model.Response "获取成功"
|
||||
// @Failure 404 {object} model.ErrorResponse "材质不存在"
|
||||
// @Router /api/v1/texture/{id} [get]
|
||||
func GetTexture(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"无效的材质ID",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
texture, err := service.GetTextureByID(database.MustGetDB(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// SearchTextures 搜索材质
|
||||
// @Summary 搜索材质
|
||||
// @Description 根据关键词和类型搜索材质
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param keyword query string false "关键词"
|
||||
// @Param type query string false "材质类型(SKIN/CAPE)"
|
||||
// @Param public_only query bool false "只看公开材质"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.PaginationResponse "搜索成功"
|
||||
// @Router /api/v1/texture [get]
|
||||
func SearchTextures(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
textureTypeStr := c.Query("type")
|
||||
publicOnly := c.Query("public_only") == "true"
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
var textureType model.TextureType
|
||||
switch textureTypeStr {
|
||||
case "SKIN":
|
||||
textureType = model.TextureTypeSkin
|
||||
case "CAPE":
|
||||
textureType = model.TextureTypeCape
|
||||
}
|
||||
|
||||
textures, total, err := service.SearchTextures(database.MustGetDB(), keyword, textureType, publicOnly, page, pageSize)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("搜索材质失败",
|
||||
zap.String("keyword", keyword),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
"搜索材质失败",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为TextureInfo
|
||||
textureInfos := make([]*types.TextureInfo, len(textures))
|
||||
for i, texture := range textures {
|
||||
textureInfos[i] = &types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
|
||||
}
|
||||
|
||||
// UpdateTexture 更新材质
|
||||
// @Summary 更新材质
|
||||
// @Description 更新材质信息(仅上传者可操作)
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "材质ID"
|
||||
// @Param request body types.UpdateTextureRequest true "更新材质请求"
|
||||
// @Success 200 {object} model.Response "更新成功"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Router /api/v1/texture/{id} [put]
|
||||
func UpdateTexture(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
idStr := c.Param("id")
|
||||
textureID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"无效的材质ID",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.UpdateTextureRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
texture, err := service.UpdateTexture(database.MustGetDB(), textureID, userID.(int64), req.Name, req.Description, req.IsPublic)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("更新材质失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Int64("texture_id", textureID),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteTexture 删除材质
|
||||
// @Summary 删除材质
|
||||
// @Description 删除材质(软删除,仅上传者可操作)
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "材质ID"
|
||||
// @Success 200 {object} model.Response "删除成功"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Router /api/v1/texture/{id} [delete]
|
||||
func DeleteTexture(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
idStr := c.Param("id")
|
||||
textureID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"无效的材质ID",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.DeleteTexture(database.MustGetDB(), textureID, userID.(int64)); err != nil {
|
||||
logger.MustGetLogger().Error("删除材质失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Int64("texture_id", textureID),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(nil))
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏状态
|
||||
// @Summary 切换收藏状态
|
||||
// @Description 收藏或取消收藏材质
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "材质ID"
|
||||
// @Success 200 {object} model.Response "切换成功"
|
||||
// @Router /api/v1/texture/{id}/favorite [post]
|
||||
func ToggleFavorite(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
idStr := c.Param("id")
|
||||
textureID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"无效的材质ID",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
isFavorited, err := service.ToggleTextureFavorite(database.MustGetDB(), userID.(int64), textureID)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("切换收藏状态失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Int64("texture_id", textureID),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]bool{
|
||||
"is_favorited": isFavorited,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUserTextures 获取用户上传的材质列表
|
||||
// @Summary 获取用户上传的材质列表
|
||||
// @Description 获取当前用户上传的所有材质
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.PaginationResponse "获取成功"
|
||||
// @Router /api/v1/texture/my [get]
|
||||
func GetUserTextures(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
textures, total, err := service.GetUserTextures(database.MustGetDB(), userID.(int64), page, pageSize)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("获取用户材质列表失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
"获取材质列表失败",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为TextureInfo
|
||||
textureInfos := make([]*types.TextureInfo, len(textures))
|
||||
for i, texture := range textures {
|
||||
textureInfos[i] = &types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
|
||||
}
|
||||
|
||||
// GetUserFavorites 获取用户收藏的材质列表
|
||||
// @Summary 获取用户收藏的材质列表
|
||||
// @Description 获取当前用户收藏的所有材质
|
||||
// @Tags texture
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.PaginationResponse "获取成功"
|
||||
// @Router /api/v1/texture/favorites [get]
|
||||
func GetUserFavorites(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
textures, total, err := service.GetUserTextureFavorites(database.MustGetDB(), userID.(int64), page, pageSize)
|
||||
if err != nil {
|
||||
logger.MustGetLogger().Error("获取用户收藏列表失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
"获取收藏列表失败",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为TextureInfo
|
||||
textureInfos := make([]*types.TextureInfo, len(textures))
|
||||
for i, texture := range textures {
|
||||
textureInfos[i] = &types.TextureInfo{
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
|
||||
}
|
||||
415
internal/handler/user_handler.go
Normal file
415
internal/handler/user_handler.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/logger"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GetUserProfile 获取用户信息
|
||||
// @Summary 获取用户信息
|
||||
// @Description 获取当前登录用户的详细信息
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} model.Response "获取成功"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Router /api/v1/user/profile [get]
|
||||
func GetUserProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
// 从上下文获取用户ID (由JWT中间件设置)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := service.GetUserByID(userID.(int64))
|
||||
if err != nil || user == nil {
|
||||
loggerInstance.Error("获取用户信息失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回用户信息
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Points: user.Points,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// UpdateUserProfile 更新用户信息
|
||||
// @Summary 更新用户信息
|
||||
// @Description 更新当前登录用户的头像和密码(修改邮箱请使用 /change-email 接口)
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.UpdateUserRequest true "更新信息(修改密码时需同时提供old_password和new_password)"
|
||||
// @Success 200 {object} model.Response{data=types.UserInfo} "更新成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Failure 404 {object} model.ErrorResponse "用户不存在"
|
||||
// @Failure 500 {object} model.ErrorResponse "服务器错误"
|
||||
// @Router /api/v1/user/profile [put]
|
||||
func UpdateUserProfile(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户
|
||||
user, err := service.GetUserByID(userID.(int64))
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 处理密码修改
|
||||
if req.NewPassword != "" {
|
||||
// 如果提供了新密码,必须同时提供旧密码
|
||||
if req.OldPassword == "" {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"修改密码需要提供原密码",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用修改密码服务
|
||||
if err := service.ChangeUserPassword(userID.(int64), req.OldPassword, req.NewPassword); err != nil {
|
||||
loggerInstance.Error("修改密码失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
loggerInstance.Info("用户修改密码成功",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
)
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
if req.Avatar != "" {
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
|
||||
// 保存更新(仅当有头像修改时)
|
||||
if req.Avatar != "" {
|
||||
if err := service.UpdateUserInfo(user); err != nil {
|
||||
loggerInstance.Error("更新用户信息失败",
|
||||
zap.Int64("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
"更新失败",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重新获取更新后的用户信息
|
||||
updatedUser, err := service.GetUserByID(userID.(int64))
|
||||
if err != nil || updatedUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回更新后的用户信息
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
|
||||
ID: updatedUser.ID,
|
||||
Username: updatedUser.Username,
|
||||
Email: updatedUser.Email,
|
||||
Avatar: updatedUser.Avatar,
|
||||
Points: updatedUser.Points,
|
||||
Role: updatedUser.Role,
|
||||
Status: updatedUser.Status,
|
||||
LastLoginAt: updatedUser.LastLoginAt,
|
||||
CreatedAt: updatedUser.CreatedAt,
|
||||
UpdatedAt: updatedUser.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURL 生成头像上传URL
|
||||
// @Summary 生成头像上传URL
|
||||
// @Description 生成预签名URL用于上传用户头像
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.GenerateAvatarUploadURLRequest true "文件名"
|
||||
// @Success 200 {object} model.Response "生成成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/user/avatar/upload-url [post]
|
||||
func GenerateAvatarUploadURL(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.GenerateAvatarUploadURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用UploadService生成预签名URL
|
||||
storageClient := storage.MustGetClient()
|
||||
cfg := *config.MustGetRustFSConfig()
|
||||
result, err := service.GenerateAvatarUploadURL(c.Request.Context(), storageClient, cfg, userID.(int64), req.FileName)
|
||||
if err != nil {
|
||||
loggerInstance.Error("生成头像上传URL失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("file_name", req.FileName),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateAvatarUploadURLResponse{
|
||||
PostURL: result.PostURL,
|
||||
FormData: result.FormData,
|
||||
AvatarURL: result.FileURL,
|
||||
ExpiresIn: 900, // 15分钟 = 900秒
|
||||
}))
|
||||
}
|
||||
|
||||
// UpdateAvatar 更新头像URL
|
||||
// @Summary 更新头像URL
|
||||
// @Description 上传完成后更新用户的头像URL到数据库
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param avatar_url query string true "头像URL"
|
||||
// @Success 200 {object} model.Response "更新成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Router /api/v1/user/avatar [put]
|
||||
func UpdateAvatar(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
avatarURL := c.Query("avatar_url")
|
||||
if avatarURL == "" {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"头像URL不能为空",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
if err := service.UpdateUserAvatar(userID.(int64), avatarURL); err != nil {
|
||||
loggerInstance.Error("更新头像失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("avatar_url", avatarURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
|
||||
model.CodeServerError,
|
||||
"更新头像失败",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的用户信息
|
||||
user, err := service.GetUserByID(userID.(int64))
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 返回更新后的用户信息
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Points: user.Points,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// ChangeEmail 更换邮箱
|
||||
// @Summary 更换邮箱
|
||||
// @Description 通过验证码更换用户邮箱
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body types.ChangeEmailRequest true "更换邮箱请求"
|
||||
// @Success 200 {object} model.Response{data=types.UserInfo} "更换成功"
|
||||
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} model.ErrorResponse "未授权"
|
||||
// @Router /api/v1/user/change-email [post]
|
||||
func ChangeEmail(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.ChangeEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"请求参数错误",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
redisClient := redis.MustGetClient()
|
||||
if err := service.VerifyCode(c.Request.Context(), redisClient, req.NewEmail, req.VerificationCode, service.VerificationTypeChangeEmail); err != nil {
|
||||
loggerInstance.Warn("验证码验证失败",
|
||||
zap.String("new_email", req.NewEmail),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更换邮箱
|
||||
if err := service.ChangeUserEmail(userID.(int64), req.NewEmail); err != nil {
|
||||
loggerInstance.Error("更换邮箱失败",
|
||||
zap.Int64("user_id", userID.(int64)),
|
||||
zap.String("new_email", req.NewEmail),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
err.Error(),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的用户信息
|
||||
user, err := service.GetUserByID(userID.(int64))
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
err,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Points: user.Points,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
151
internal/handler/user_handler_test.go
Normal file
151
internal/handler/user_handler_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUserHandler_PermissionCheck 测试权限检查逻辑
|
||||
func TestUserHandler_PermissionCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID interface{}
|
||||
exists bool
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的用户ID",
|
||||
userID: int64(1),
|
||||
exists: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "用户ID不存在",
|
||||
userID: nil,
|
||||
exists: false,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "用户ID类型错误",
|
||||
userID: "invalid",
|
||||
exists: true,
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证权限检查逻辑
|
||||
isValid := tt.exists
|
||||
if tt.exists {
|
||||
// 验证类型转换
|
||||
if _, ok := tt.userID.(int64); !ok {
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Permission check failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserHandler_RequestValidation 测试请求验证逻辑
|
||||
func TestUserHandler_RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
avatar string
|
||||
oldPass string
|
||||
newPass string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "只更新头像",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
oldPass: "",
|
||||
newPass: "",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "更新密码(提供旧密码和新密码)",
|
||||
avatar: "",
|
||||
oldPass: "oldpass123",
|
||||
newPass: "newpass123",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "只提供新密码(无效)",
|
||||
avatar: "",
|
||||
oldPass: "",
|
||||
newPass: "newpass123",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "只提供旧密码(无效)",
|
||||
avatar: "",
|
||||
oldPass: "oldpass123",
|
||||
newPass: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证请求逻辑:更新密码时需要同时提供旧密码和新密码
|
||||
isValid := true
|
||||
if tt.newPass != "" && tt.oldPass == "" {
|
||||
isValid = false
|
||||
}
|
||||
if tt.oldPass != "" && tt.newPass == "" {
|
||||
isValid = false
|
||||
}
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Request validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserHandler_ErrorHandling 测试错误处理逻辑
|
||||
func TestUserHandler_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType string
|
||||
wantCode int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "未授权",
|
||||
errType: "unauthorized",
|
||||
wantCode: 401,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "用户不存在",
|
||||
errType: "not_found",
|
||||
wantCode: 404,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "参数错误",
|
||||
errType: "bad_request",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "服务器错误",
|
||||
errType: "server_error",
|
||||
wantCode: 500,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑
|
||||
if !tt.wantError {
|
||||
t.Error("Error handling test should expect error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
666
internal/handler/yggdrasil_handler.go
Normal file
666
internal/handler/yggdrasil_handler.go
Normal file
@@ -0,0 +1,666 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/logger"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 常量定义
|
||||
const (
|
||||
ErrInternalServer = "服务器内部错误"
|
||||
// 错误类型
|
||||
ErrInvalidEmailFormat = "邮箱格式不正确"
|
||||
ErrInvalidPassword = "密码必须至少包含8个字符,只能包含字母、数字和特殊字符"
|
||||
ErrWrongPassword = "密码错误"
|
||||
ErrUserNotMatch = "用户不匹配"
|
||||
|
||||
// 错误消息
|
||||
ErrInvalidRequest = "请求格式无效"
|
||||
ErrJoinServerFailed = "加入服务器失败"
|
||||
ErrServerIDRequired = "服务器ID不能为空"
|
||||
ErrUsernameRequired = "用户名不能为空"
|
||||
ErrSessionVerifyFailed = "会话验证失败"
|
||||
ErrProfileNotFound = "未找到用户配置文件"
|
||||
ErrInvalidParams = "无效的请求参数"
|
||||
ErrEmptyUserID = "用户ID为空"
|
||||
ErrUnauthorized = "无权操作此配置文件"
|
||||
ErrGetProfileService = "获取配置文件服务失败"
|
||||
|
||||
// 成功信息
|
||||
SuccessProfileCreated = "创建成功"
|
||||
MsgRegisterSuccess = "注册成功"
|
||||
|
||||
// 错误消息
|
||||
ErrGetProfile = "获取配置文件失败"
|
||||
ErrGetTextureService = "获取材质服务失败"
|
||||
ErrInvalidContentType = "无效的请求内容类型"
|
||||
ErrParseMultipartForm = "解析多部分表单失败"
|
||||
ErrGetFileFromForm = "从表单获取文件失败"
|
||||
ErrInvalidFileType = "无效的文件类型,仅支持PNG图片"
|
||||
ErrSaveTexture = "保存材质失败"
|
||||
ErrSetTexture = "设置材质失败"
|
||||
ErrGetTexture = "获取材质失败"
|
||||
|
||||
// 内存限制
|
||||
MaxMultipartMemory = 32 << 20 // 32 MB
|
||||
|
||||
// 材质类型
|
||||
TextureTypeSkin = "SKIN"
|
||||
TextureTypeCape = "CAPE"
|
||||
|
||||
// 内容类型
|
||||
ContentTypePNG = "image/png"
|
||||
ContentTypeMultipart = "multipart/form-data"
|
||||
|
||||
// 表单参数
|
||||
FormKeyModel = "model"
|
||||
FormKeyFile = "file"
|
||||
|
||||
// 元数据键
|
||||
MetaKeyModel = "model"
|
||||
)
|
||||
|
||||
// 正则表达式
|
||||
var (
|
||||
// 邮箱正则表达式
|
||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// 密码强度正则表达式(最少8位,只允许字母、数字和特定特殊字符)
|
||||
passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*]{8,}$`)
|
||||
)
|
||||
|
||||
// 请求结构体
|
||||
type (
|
||||
// AuthenticateRequest 认证请求
|
||||
AuthenticateRequest struct {
|
||||
Agent map[string]interface{} `json:"agent"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
Identifier string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
RequestUser bool `json:"requestUser"`
|
||||
}
|
||||
|
||||
// ValidTokenRequest 验证令牌请求
|
||||
ValidTokenRequest struct {
|
||||
AccessToken string `json:"accessToken" binding:"required"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
}
|
||||
|
||||
// RefreshRequest 刷新令牌请求
|
||||
RefreshRequest struct {
|
||||
AccessToken string `json:"accessToken" binding:"required"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
RequestUser bool `json:"requestUser"`
|
||||
SelectedProfile map[string]interface{} `json:"selectedProfile"`
|
||||
}
|
||||
|
||||
// SignOutRequest 登出请求
|
||||
SignOutRequest struct {
|
||||
Email string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
JoinServerRequest struct {
|
||||
ServerID string `json:"serverId" binding:"required"`
|
||||
AccessToken string `json:"accessToken" binding:"required"`
|
||||
SelectedProfile string `json:"selectedProfile" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// 响应结构体
|
||||
type (
|
||||
// AuthenticateResponse 认证响应
|
||||
AuthenticateResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
|
||||
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
|
||||
User map[string]interface{} `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshResponse 刷新令牌响应
|
||||
RefreshResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
|
||||
User map[string]interface{} `json:"user,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Status int `json:"status"`
|
||||
Data interface{} `json:"data"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
|
||||
// standardResponse 生成标准响应
|
||||
func standardResponse(c *gin.Context, status int, data interface{}, err interface{}) {
|
||||
c.JSON(status, APIResponse{
|
||||
Status: status,
|
||||
Data: data,
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticate 用户认证
|
||||
func Authenticate(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
// 读取并保存原始请求体,以便多次读取
|
||||
rawData, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 读取请求体失败: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
|
||||
|
||||
// 绑定JSON数据到请求结构体
|
||||
var request AuthenticateRequest
|
||||
if err = c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析认证请求失败: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据标识符类型(邮箱或用户名)获取用户
|
||||
var userId int64
|
||||
var profile *model.Profile
|
||||
var UUID string
|
||||
if emailRegex.MatchString(request.Identifier) {
|
||||
userId, err = service.GetUserIDByEmail(db, request.Identifier)
|
||||
} else {
|
||||
profile, err = service.GetProfileByProfileName(db, request.Identifier)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 用户名不存在: ", zap.String("标识符", request.Identifier), zap.Error(err))
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userId = profile.UserID
|
||||
UUID = profile.UUID
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
loggerInstance.Warn("[WARN] 认证失败: 用户不存在",
|
||||
zap.String("标识符:", request.Identifier),
|
||||
zap.Error(err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
err = service.VerifyPassword(db, request.Password, userId)
|
||||
if err != nil {
|
||||
loggerInstance.Warn("[WARN] 认证失败:", zap.Error(err))
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": ErrWrongPassword})
|
||||
return
|
||||
}
|
||||
// 生成新令牌
|
||||
selectedProfile, availableProfiles, accessToken, clientToken, err := service.NewToken(db, loggerInstance, userId, UUID, request.ClientToken)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 生成令牌失败:", zap.Error(err), zap.Any("用户ID:", userId))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := service.GetUserByID(userId)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] id查找错误:", zap.Error(err), zap.Any("ID:", userId))
|
||||
}
|
||||
// 处理可用的配置文件
|
||||
redisClient := redis.MustGetClient()
|
||||
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
|
||||
for _, profile := range availableProfiles {
|
||||
availableProfilesData = append(availableProfilesData, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
|
||||
}
|
||||
response := AuthenticateResponse{
|
||||
AccessToken: accessToken,
|
||||
ClientToken: clientToken,
|
||||
AvailableProfiles: availableProfilesData,
|
||||
}
|
||||
if selectedProfile != nil {
|
||||
response.SelectedProfile = service.SerializeProfile(db, loggerInstance, redisClient, *selectedProfile)
|
||||
}
|
||||
if request.RequestUser {
|
||||
response.User = map[string]interface{}{
|
||||
"id": userId,
|
||||
"properties": user.Properties,
|
||||
}
|
||||
}
|
||||
|
||||
// 返回认证响应
|
||||
loggerInstance.Info("[INFO] 用户认证成功", zap.Any("用户ID:", userId))
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ValidToken 验证令牌
|
||||
func ValidToken(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
var request ValidTokenRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析验证令牌请求失败: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 验证令牌
|
||||
if service.ValidToken(db, request.AccessToken, request.ClientToken) {
|
||||
loggerInstance.Info("[INFO] 令牌验证成功", zap.Any("访问令牌:", request.AccessToken))
|
||||
c.JSON(http.StatusNoContent, gin.H{"valid": true})
|
||||
} else {
|
||||
loggerInstance.Warn("[WARN] 令牌验证失败", zap.Any("访问令牌:", request.AccessToken))
|
||||
c.JSON(http.StatusForbidden, gin.H{"valid": false})
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshToken 刷新令牌
|
||||
func RefreshToken(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
var request RefreshRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析刷新令牌请求失败: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户ID和用户信息
|
||||
UUID, err := service.GetUUIDByAccessToken(db, request.AccessToken)
|
||||
if err != nil {
|
||||
loggerInstance.Warn("[WARN] 刷新令牌失败: 无效的访问令牌", zap.Any("令牌:", request.AccessToken), zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID, _ := service.GetUserIDByAccessToken(db, request.AccessToken)
|
||||
// 格式化UUID 这里是因为HMCL的传入参数是HEX格式,为了兼容HMCL,在此做处理
|
||||
UUID = utils.FormatUUID(UUID)
|
||||
|
||||
profile, err := service.GetProfileByUUID(db, UUID)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 刷新令牌失败: 无法获取用户信息 错误: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 准备响应数据
|
||||
var profileData map[string]interface{}
|
||||
var userData map[string]interface{}
|
||||
var profileID string
|
||||
|
||||
// 处理选定的配置文件
|
||||
if request.SelectedProfile != nil {
|
||||
// 验证profileID是否存在
|
||||
profileIDValue, ok := request.SelectedProfile["id"]
|
||||
if !ok {
|
||||
loggerInstance.Error("[ERROR] 刷新令牌失败: 缺少配置文件ID", zap.Any("ID:", userID))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少配置文件ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 类型断言
|
||||
profileID, ok = profileIDValue.(string)
|
||||
if !ok {
|
||||
loggerInstance.Error("[ERROR] 刷新令牌失败: 配置文件ID类型错误 ", zap.Any("用户ID:", userID))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "配置文件ID必须是字符串"})
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化profileID
|
||||
profileID = utils.FormatUUID(profileID)
|
||||
|
||||
// 验证配置文件所属用户
|
||||
if profile.UserID != userID {
|
||||
loggerInstance.Warn("[WARN] 刷新令牌失败: 用户不匹配 ", zap.Any("用户ID:", userID), zap.Any("配置文件用户ID:", profile.UserID))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": ErrUserNotMatch})
|
||||
return
|
||||
}
|
||||
|
||||
profileData = service.SerializeProfile(db, loggerInstance, redis.MustGetClient(), *profile)
|
||||
}
|
||||
user, _ := service.GetUserByID(userID)
|
||||
// 添加用户信息(如果请求了)
|
||||
if request.RequestUser {
|
||||
userData = service.SerializeUser(loggerInstance, user, UUID)
|
||||
}
|
||||
|
||||
// 刷新令牌
|
||||
newAccessToken, newClientToken, err := service.RefreshToken(db, loggerInstance,
|
||||
request.AccessToken,
|
||||
request.ClientToken,
|
||||
profileID,
|
||||
)
|
||||
if err != nil {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
loggerInstance.Error("[ERROR] 刷新令牌失败: ", zap.Error(err), zap.Any("用户ID: ", userID))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
loggerInstance.Info("[INFO] 刷新令牌成功", zap.Any("用户ID:", userID))
|
||||
c.JSON(http.StatusOK, RefreshResponse{
|
||||
AccessToken: newAccessToken,
|
||||
ClientToken: newClientToken,
|
||||
SelectedProfile: profileData,
|
||||
User: userData,
|
||||
})
|
||||
}
|
||||
|
||||
// InvalidToken 使令牌失效
|
||||
func InvalidToken(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
var request ValidTokenRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析使令牌失效请求失败: ", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 使令牌失效
|
||||
service.InvalidToken(db, loggerInstance, request.AccessToken)
|
||||
loggerInstance.Info("[INFO] 令牌已使失效", zap.Any("访问令牌:", request.AccessToken))
|
||||
c.JSON(http.StatusNoContent, gin.H{})
|
||||
}
|
||||
|
||||
// SignOut 用户登出
|
||||
func SignOut(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
var request SignOutRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析登出请求失败: %v", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
if !emailRegex.MatchString(request.Email) {
|
||||
loggerInstance.Warn("[WARN] 登出失败: 邮箱格式不正确 ", zap.Any(" ", request.Email))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat})
|
||||
return
|
||||
}
|
||||
|
||||
// 通过邮箱获取用户
|
||||
user, err := service.GetUserByEmail(request.Email)
|
||||
if err != nil {
|
||||
loggerInstance.Warn(
|
||||
"登出失败: 用户不存在",
|
||||
zap.String("邮箱", request.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
password, err := service.GetPasswordByUserId(db, user.ID)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 邮箱查找失败", zap.Any("UserId:", user.ID), zap.Error(err))
|
||||
}
|
||||
// 验证密码
|
||||
if password != request.Password {
|
||||
loggerInstance.Warn("[WARN] 登出失败: 密码错误", zap.Any("用户ID:", user.ID))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": ErrWrongPassword})
|
||||
return
|
||||
}
|
||||
|
||||
// 使该用户的所有令牌失效
|
||||
service.InvalidUserTokens(db, loggerInstance, user.ID)
|
||||
loggerInstance.Info("[INFO] 用户登出成功", zap.Any("用户ID:", user.ID))
|
||||
c.JSON(http.StatusNoContent, gin.H{"valid": true})
|
||||
}
|
||||
|
||||
func GetProfileByUUID(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
// 获取并格式化UUID
|
||||
uuid := utils.FormatUUID(c.Param("uuid"))
|
||||
loggerInstance.Info("[INFO] 接收到获取配置文件请求", zap.Any("UUID:", uuid))
|
||||
|
||||
// 获取配置文件
|
||||
profile, err := service.GetProfileByUUID(db, uuid)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 获取配置文件失败:", zap.Error(err), zap.String("UUID:", uuid))
|
||||
standardResponse(c, http.StatusInternalServerError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回配置文件信息
|
||||
loggerInstance.Info("[INFO] 成功获取配置文件", zap.String("UUID:", uuid), zap.String("名称:", profile.Name))
|
||||
c.JSON(http.StatusOK, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
|
||||
}
|
||||
|
||||
func JoinServer(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
var request JoinServerRequest
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// 解析请求参数
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
loggerInstance.Error(
|
||||
"解析加入服务器请求失败",
|
||||
zap.Error(err),
|
||||
zap.String("IP", clientIP),
|
||||
)
|
||||
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidRequest)
|
||||
return
|
||||
}
|
||||
|
||||
loggerInstance.Info(
|
||||
"收到加入服务器请求",
|
||||
zap.String("服务器ID", request.ServerID),
|
||||
zap.String("用户UUID", request.SelectedProfile),
|
||||
zap.String("IP", clientIP),
|
||||
)
|
||||
|
||||
// 处理加入服务器请求
|
||||
if err := service.JoinServer(db, loggerInstance, redisClient, request.ServerID, request.AccessToken, request.SelectedProfile, clientIP); err != nil {
|
||||
loggerInstance.Error(
|
||||
"加入服务器失败",
|
||||
zap.Error(err),
|
||||
zap.String("服务器ID", request.ServerID),
|
||||
zap.String("用户UUID", request.SelectedProfile),
|
||||
zap.String("IP", clientIP),
|
||||
)
|
||||
standardResponse(c, http.StatusInternalServerError, nil, ErrJoinServerFailed)
|
||||
return
|
||||
}
|
||||
|
||||
// 加入成功,返回204状态码
|
||||
loggerInstance.Info(
|
||||
"加入服务器成功",
|
||||
zap.String("服务器ID", request.ServerID),
|
||||
zap.String("用户UUID", request.SelectedProfile),
|
||||
zap.String("IP", clientIP),
|
||||
)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func HasJoinedServer(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
clientIP, _ := c.GetQuery("ip")
|
||||
|
||||
// 获取并验证服务器ID参数
|
||||
serverID, exists := c.GetQuery("serverId")
|
||||
if !exists || serverID == "" {
|
||||
loggerInstance.Warn("[WARN] 缺少服务器ID参数", zap.Any("IP:", clientIP))
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取并验证用户名参数
|
||||
username, exists := c.GetQuery("username")
|
||||
if !exists || username == "" {
|
||||
loggerInstance.Warn("[WARN] 缺少用户名参数", zap.Any("服务器ID:", serverID), zap.Any("IP:", clientIP))
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired)
|
||||
return
|
||||
}
|
||||
|
||||
loggerInstance.Info("[INFO] 收到会话验证请求", zap.Any("服务器ID:", serverID), zap.Any("用户名: ", username), zap.Any("IP: ", clientIP))
|
||||
|
||||
// 验证玩家是否已加入服务器
|
||||
if err := service.HasJoinedServer(loggerInstance, redisClient, serverID, username, clientIP); err != nil {
|
||||
loggerInstance.Warn("[WARN] 会话验证失败",
|
||||
zap.Error(err),
|
||||
zap.String("serverID", serverID),
|
||||
zap.String("username", username),
|
||||
zap.String("clientIP", clientIP),
|
||||
)
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed)
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := service.GetProfileByUUID(db, username)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 获取用户配置文件失败: %v - 用户名: %s",
|
||||
zap.Error(err), // 错误详情(zap 原生支持,保留错误链)
|
||||
zap.String("username", username), // 结构化存储用户名(便于检索)
|
||||
)
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回玩家配置文件
|
||||
loggerInstance.Info("[INFO] 会话验证成功 - 服务器ID: %s, 用户名: %s, UUID: %s",
|
||||
zap.String("serverID", serverID), // 结构化存储服务器ID
|
||||
zap.String("username", username), // 结构化存储用户名
|
||||
zap.String("UUID", profile.UUID), // 结构化存储UUID
|
||||
)
|
||||
c.JSON(200, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
|
||||
}
|
||||
|
||||
func GetProfilesByName(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
|
||||
var names []string
|
||||
|
||||
// 解析请求参数
|
||||
if err := c.ShouldBindJSON(&names); err != nil {
|
||||
loggerInstance.Error("[ERROR] 解析名称数组请求失败: ",
|
||||
zap.Error(err),
|
||||
)
|
||||
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams)
|
||||
return
|
||||
}
|
||||
loggerInstance.Info("[INFO] 接收到批量获取配置文件请求",
|
||||
zap.Int("名称数量:", len(names)), // 结构化存储名称数量
|
||||
)
|
||||
|
||||
// 批量获取配置文件
|
||||
profiles, err := service.GetProfilesDataByNames(db, names)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 获取配置文件失败: ",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 改造:zap 兼容原有 INFO 日志格式
|
||||
loggerInstance.Info("[INFO] 成功获取配置文件",
|
||||
zap.Int("请求名称数:", len(names)),
|
||||
zap.Int("返回结果数: ", len(profiles)),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, profiles)
|
||||
}
|
||||
|
||||
func GetMetaData(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
meta := gin.H{
|
||||
"implementationName": "CellAuth",
|
||||
"implementationVersion": "0.0.1",
|
||||
"serverName": "LittleLan's Yggdrasil Server Implementation.",
|
||||
"links": gin.H{
|
||||
"homepage": "https://skin.littlelan.cn",
|
||||
"register": "https://skin.littlelan.cn/auth",
|
||||
},
|
||||
"feature.non_email_login": true,
|
||||
"feature.enable_profile_key": true,
|
||||
}
|
||||
skinDomains := []string{".hitwh.games", ".littlelan.cn"}
|
||||
signature, err := service.GetPublicKeyFromRedisFunc(loggerInstance, redisClient)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 获取公钥失败: ", zap.Error(err))
|
||||
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
|
||||
return
|
||||
}
|
||||
|
||||
loggerInstance.Info("[INFO] 提供元数据")
|
||||
c.JSON(http.StatusOK, gin.H{"meta": meta,
|
||||
"skinDomains": skinDomains,
|
||||
"signaturePublickey": signature})
|
||||
}
|
||||
|
||||
func GetPlayerCertificates(c *gin.Context) {
|
||||
loggerInstance := logger.MustGetLogger()
|
||||
db := database.MustGetDB()
|
||||
redisClient := redis.MustGetClient()
|
||||
|
||||
var uuid string
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header not provided"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否以 Bearer 开头并提取 sessionID
|
||||
bearerPrefix := "Bearer "
|
||||
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tokenID := authHeader[len(bearerPrefix):]
|
||||
if tokenID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var err error
|
||||
uuid, err = service.GetUUIDByAccessToken(db, tokenID)
|
||||
|
||||
if uuid == "" {
|
||||
loggerInstance.Error("[ERROR] 获取玩家UUID失败: ", zap.Error(err))
|
||||
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化UUID
|
||||
uuid = utils.FormatUUID(uuid)
|
||||
|
||||
// 生成玩家证书
|
||||
certificate, err := service.GeneratePlayerCertificate(db, loggerInstance, redisClient, uuid)
|
||||
if err != nil {
|
||||
loggerInstance.Error("[ERROR] 生成玩家证书失败: ", zap.Error(err))
|
||||
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
|
||||
return
|
||||
}
|
||||
|
||||
loggerInstance.Info("[INFO] 成功生成玩家证书")
|
||||
c.JSON(http.StatusOK, certificate)
|
||||
}
|
||||
157
internal/handler/yggdrasil_handler_test.go
Normal file
157
internal/handler/yggdrasil_handler_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestYggdrasilHandler_EmailValidation 测试邮箱验证逻辑
|
||||
func TestYggdrasilHandler_EmailValidation(t *testing.T) {
|
||||
// 使用简单的邮箱正则表达式
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的邮箱",
|
||||
email: "test@example.com",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "无效的邮箱格式",
|
||||
email: "invalid-email",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "缺少@符号",
|
||||
email: "testexample.com",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "缺少域名",
|
||||
email: "test@",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "空邮箱",
|
||||
email: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := emailRegex.MatchString(tt.email)
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Email validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYggdrasilHandler_RequestValidation 测试请求验证逻辑
|
||||
func TestYggdrasilHandler_RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accessToken string
|
||||
serverID string
|
||||
username string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的请求",
|
||||
accessToken: "token-123",
|
||||
serverID: "server-456",
|
||||
username: "player",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "accessToken为空",
|
||||
accessToken: "",
|
||||
serverID: "server-456",
|
||||
username: "player",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "serverID为空",
|
||||
accessToken: "token-123",
|
||||
serverID: "",
|
||||
username: "player",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "username为空",
|
||||
accessToken: "token-123",
|
||||
serverID: "server-456",
|
||||
username: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.accessToken != "" && tt.serverID != "" && tt.username != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Request validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYggdrasilHandler_ErrorHandling 测试错误处理逻辑
|
||||
func TestYggdrasilHandler_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType string
|
||||
wantCode int
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "参数错误",
|
||||
errType: "bad_request",
|
||||
wantCode: 400,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "未授权",
|
||||
errType: "forbidden",
|
||||
wantCode: 403,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "服务器错误",
|
||||
errType: "server_error",
|
||||
wantCode: 500,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑
|
||||
if !tt.wantError {
|
||||
t.Error("Error handling test should expect error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYggdrasilHandler_Constants 测试常量定义
|
||||
func TestYggdrasilHandler_Constants(t *testing.T) {
|
||||
// 验证常量定义
|
||||
if MaxMultipartMemory != 32<<20 {
|
||||
t.Errorf("MaxMultipartMemory = %d, want %d", MaxMultipartMemory, 32<<20)
|
||||
}
|
||||
|
||||
if TextureTypeSkin != "SKIN" {
|
||||
t.Errorf("TextureTypeSkin = %q, want 'SKIN'", TextureTypeSkin)
|
||||
}
|
||||
|
||||
if TextureTypeCape != "CAPE" {
|
||||
t.Errorf("TextureTypeCape = %q, want 'CAPE'", TextureTypeCape)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user