2 Commits

Author SHA1 Message Date
lan
13bab28926 feat: 增加登录和验证码验证失败次数限制,添加账号锁定机制
Some checks failed
SonarQube Analysis / sonarqube (push) Has been cancelled
2025-12-02 10:38:25 +08:00
lan
10fdcd916b feat: 添加种子数据初始化功能,重构多个处理程序以简化错误响应和用户验证 2025-12-02 10:33:19 +08:00
30 changed files with 1311 additions and 1778 deletions

View File

@@ -49,6 +49,11 @@ func main() {
loggerInstance.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
if err := database.Seed(loggerInstance); err != nil {
loggerInstance.Fatal("种子数据初始化失败", zap.Error(err))
}
// 初始化JWT服务
if err := auth.Init(cfg.JWT); err != nil {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))

View File

@@ -1,15 +1,13 @@
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"
)
@@ -28,59 +26,32 @@ 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,
))
RespondBadRequest(c, "请求参数错误", 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,
))
loggerInstance.Warn("验证码验证失败", zap.String("email", req.Email), zap.Error(err))
RespondBadRequest(c, 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,
))
RespondBadRequest(c, 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,
},
}))
RespondSuccess(c, &types.LoginResponse{
Token: token,
UserInfo: UserToUserInfo(user),
})
}
// Login 用户登录
@@ -97,53 +68,32 @@ func Register(c *gin.Context) {
func Login(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
jwtService := auth.MustGetJWTService()
redisClient := redis.MustGetClient()
var req types.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", 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)
user, token, err := service.LoginUserWithRateLimit(redisClient, 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,
))
RespondUnauthorized(c, err.Error())
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,
},
}))
RespondSuccess(c, &types.LoginResponse{
Token: token,
UserInfo: UserToUserInfo(user),
})
}
// SendVerificationCode 发送验证码
@@ -160,35 +110,24 @@ 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,
))
RespondBadRequest(c, "请求参数错误", 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,
))
RespondBadRequest(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "验证码已发送,请查收邮件",
}))
RespondSuccess(c, gin.H{"message": "验证码已发送,请查收邮件"})
}
// ResetPassword 重置密码
@@ -204,46 +143,26 @@ func SendVerificationCode(c *gin.Context) {
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,
))
RespondBadRequest(c, "请求参数错误", 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,
))
loggerInstance.Warn("验证码验证失败", zap.String("email", req.Email), zap.Error(err))
RespondBadRequest(c, 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,
))
loggerInstance.Error("重置密码失败", zap.String("email", req.Email), zap.Error(err))
RespondServerError(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "密码重置成功",
}))
RespondSuccess(c, gin.H{"message": "密码重置成功"})
}

160
internal/handler/helpers.go Normal file
View File

@@ -0,0 +1,160 @@
package handler
import (
"carrotskin/internal/model"
"carrotskin/internal/types"
"net/http"
"github.com/gin-gonic/gin"
)
// GetUserIDFromContext 从上下文获取用户ID如果不存在返回未授权响应
// 返回值: userID, ok (如果ok为false已经发送了错误响应)
func GetUserIDFromContext(c *gin.Context) (int64, bool) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
return 0, false
}
return userID.(int64), true
}
// UserToUserInfo 将 User 模型转换为 UserInfo 响应
func UserToUserInfo(user *model.User) *types.UserInfo {
return &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,
}
}
// ProfileToProfileInfo 将 Profile 模型转换为 ProfileInfo 响应
func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
return &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,
}
}
// ProfilesToProfileInfos 批量转换 Profile 模型为 ProfileInfo 响应
func ProfilesToProfileInfos(profiles []*model.Profile) []*types.ProfileInfo {
result := make([]*types.ProfileInfo, 0, len(profiles))
for _, profile := range profiles {
result = append(result, ProfileToProfileInfo(profile))
}
return result
}
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
return &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,
}
}
// TexturesToTextureInfos 批量转换 Texture 模型为 TextureInfo 响应
func TexturesToTextureInfos(textures []*model.Texture) []*types.TextureInfo {
result := make([]*types.TextureInfo, len(textures))
for i, texture := range textures {
result[i] = TextureToTextureInfo(texture)
}
return result
}
// RespondBadRequest 返回400错误响应
func RespondBadRequest(c *gin.Context, message string, err error) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
message,
err,
))
}
// RespondUnauthorized 返回401错误响应
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
message,
nil,
))
}
// RespondForbidden 返回403错误响应
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
message,
nil,
))
}
// RespondNotFound 返回404错误响应
func RespondNotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
message,
nil,
))
}
// RespondServerError 返回500错误响应
func RespondServerError(c *gin.Context, message string, err error) {
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
message,
err,
))
}
// RespondSuccess 返回成功响应
func RespondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, model.NewSuccessResponse(data))
}
// RespondWithError 根据错误消息自动选择状态码
func RespondWithError(c *gin.Context, err error) {
msg := err.Error()
switch msg {
case "档案不存在", "用户不存在", "材质不存在":
RespondNotFound(c, msg)
case "无权操作此档案", "无权操作此材质":
RespondForbidden(c, msg)
case "未授权":
RespondUnauthorized(c, msg)
default:
RespondServerError(c, msg, nil)
}
}

View File

@@ -1,12 +1,10 @@
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"
@@ -26,70 +24,37 @@ import (
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 解析请求
var req types.CreateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误: "+err.Error(),
nil,
))
RespondBadRequest(c, "请求参数错误: "+err.Error(), nil)
return
}
// TODO: 从配置或数据库读取限制
maxProfiles := 5
maxProfiles := service.GetMaxProfilesPerUser()
db := database.MustGetDB()
// 检查档案数量限制
if err := service.CheckProfileLimit(db, userID.(int64), maxProfiles); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
if err := service.CheckProfileLimit(db, userID, maxProfiles); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
// 创建档案
profile, err := service.CreateProfile(db, userID.(int64), req.Name)
profile, err := service.CreateProfile(db, userID, req.Name)
if err != nil {
loggerInstance.Error("创建档案失败",
zap.Int64("user_id", userID.(int64)),
logger.MustGetLogger().Error("创建档案失败",
zap.Int64("user_id", userID),
zap.String("name", req.Name),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondServerError(c, 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,
}))
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// GetProfiles 获取档案列表
@@ -104,50 +69,22 @@ func CreateProfile(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 查询档案列表
profiles, err := service.GetUserProfiles(database.MustGetDB(), userID.(int64))
profiles, err := service.GetUserProfiles(database.MustGetDB(), userID)
if err != nil {
loggerInstance.Error("获取档案列表失败",
zap.Int64("user_id", userID.(int64)),
logger.MustGetLogger().Error("获取档案列表失败",
zap.Int64("user_id", userID),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondServerError(c, 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))
RespondSuccess(c, ProfilesToProfileInfos(profiles))
}
// GetProfile 获取档案详情
@@ -162,36 +99,19 @@ func GetProfiles(c *gin.Context) {
// @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("获取档案失败",
logger.MustGetLogger().Error("获取档案失败",
zap.String("uuid", uuid),
zap.Error(err),
)
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
err.Error(),
nil,
))
RespondNotFound(c, err.Error())
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,
}))
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// UpdateProfile 更新档案
@@ -211,72 +131,36 @@ func GetProfile(c *gin.Context) {
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid} [put]
func UpdateProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
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,
))
RespondBadRequest(c, "请求参数错误: "+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)
profile, err := service.UpdateProfile(database.MustGetDB(), uuid, userID, namePtr, req.SkinID, req.CapeID)
if err != nil {
loggerInstance.Error("更新档案失败",
logger.MustGetLogger().Error("更新档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
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,
))
RespondWithError(c, err)
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,
}))
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// DeleteProfile 删除档案
@@ -294,48 +178,25 @@ func UpdateProfile(c *gin.Context) {
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid} [delete]
func DeleteProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
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))
err := service.DeleteProfile(database.MustGetDB(), uuid, userID)
if err != nil {
loggerInstance.Error("删除档案失败",
logger.MustGetLogger().Error("删除档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
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,
))
RespondWithError(c, err)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "删除成功",
}))
RespondSuccess(c, gin.H{"message": "删除成功"})
}
// SetActiveProfile 设置活跃档案
@@ -353,46 +214,23 @@ func DeleteProfile(c *gin.Context) {
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid}/activate [post]
func SetActiveProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
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))
err := service.SetActiveProfile(database.MustGetDB(), uuid, userID)
if err != nil {
loggerInstance.Error("设置活跃档案失败",
logger.MustGetLogger().Error("设置活跃档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
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,
))
RespondWithError(c, err)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "设置成功",
}))
RespondSuccess(c, gin.H{"message": "设置成功"})
}

View File

@@ -8,7 +8,6 @@ import (
"carrotskin/pkg/database"
"carrotskin/pkg/logger"
"carrotskin/pkg/storage"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -27,59 +26,44 @@ import (
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateTextureUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 调用UploadService生成预签名URL
storageClient := storage.MustGetClient()
cfg := *config.MustGetRustFSConfig()
result, err := service.GenerateTextureUploadURL(
c.Request.Context(),
storageClient,
cfg,
userID.(int64),
userID,
req.FileName,
string(req.TextureType),
)
if err != nil {
logger.MustGetLogger().Error("生成材质上传URL失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
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,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateTextureUploadURLResponse{
RespondSuccess(c, &types.GenerateTextureUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
TextureURL: result.FileURL,
ExpiresIn: 900, // 15分钟 = 900秒
}))
ExpiresIn: 900,
})
}
// CreateTexture 创建材质记录
@@ -94,40 +78,25 @@ func GenerateTextureUploadURL(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.CreateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", 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,
))
maxTextures := service.GetMaxTexturesPerUser()
if err := service.CheckTextureUploadLimit(database.MustGetDB(), userID, maxTextures); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
// 创建材质
texture, err := service.CreateTexture(database.MustGetDB(),
userID.(int64),
userID,
req.Name,
req.Description,
string(req.Type),
@@ -139,36 +108,15 @@ func CreateTexture(c *gin.Context) {
)
if err != nil {
logger.MustGetLogger().Error("创建材质失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.String("name", req.Name),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, 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,
}))
RespondSuccess(c, TextureToTextureInfo(texture))
}
// GetTexture 获取材质详情
@@ -182,44 +130,19 @@ func CreateTexture(c *gin.Context) {
// @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)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
texture, err := service.GetTextureByID(database.MustGetDB(), id)
if err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
err.Error(),
nil,
))
RespondNotFound(c, err.Error())
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,
}))
RespondSuccess(c, TextureToTextureInfo(texture))
}
// SearchTextures 搜索材质
@@ -253,41 +176,12 @@ func SearchTextures(c *gin.Context) {
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,
))
logger.MustGetLogger().Error("搜索材质失败", zap.String("keyword", keyword), zap.Error(err))
RespondServerError(c, "搜索材质失败", 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))
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize))
}
// UpdateTexture 更新材质
@@ -303,69 +197,35 @@ func SearchTextures(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
var req types.UpdateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
texture, err := service.UpdateTexture(database.MustGetDB(), textureID, userID.(int64), req.Name, req.Description, req.IsPublic)
texture, err := service.UpdateTexture(database.MustGetDB(), textureID, userID, req.Name, req.Description, req.IsPublic)
if err != nil {
logger.MustGetLogger().Error("更新材质失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
err.Error(),
nil,
))
RespondForbidden(c, err.Error())
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,
}))
RespondSuccess(c, TextureToTextureInfo(texture))
}
// DeleteTexture 删除材质
@@ -380,42 +240,28 @@ func UpdateTexture(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
if err := service.DeleteTexture(database.MustGetDB(), textureID, userID.(int64)); err != nil {
if err := service.DeleteTexture(database.MustGetDB(), textureID, userID); err != nil {
logger.MustGetLogger().Error("删除材质失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
err.Error(),
nil,
))
RespondForbidden(c, err.Error())
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(nil))
RespondSuccess(c, nil)
}
// ToggleFavorite 切换收藏状态
@@ -429,45 +275,29 @@ func DeleteTexture(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
isFavorited, err := service.ToggleTextureFavorite(database.MustGetDB(), userID.(int64), textureID)
isFavorited, err := service.ToggleTextureFavorite(database.MustGetDB(), userID, textureID)
if err != nil {
logger.MustGetLogger().Error("切换收藏状态失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]bool{
"is_favorited": isFavorited,
}))
RespondSuccess(c, map[string]bool{"is_favorited": isFavorited})
}
// GetUserTextures 获取用户上传的材质列表
@@ -482,56 +312,22 @@ func ToggleFavorite(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
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)
textures, total, err := service.GetUserTextures(database.MustGetDB(), userID, 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,
))
logger.MustGetLogger().Error("获取用户材质列表失败", zap.Int64("user_id", userID), zap.Error(err))
RespondServerError(c, "获取材质列表失败", 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))
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize))
}
// GetUserFavorites 获取用户收藏的材质列表
@@ -546,54 +342,20 @@ func GetUserTextures(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
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)
textures, total, err := service.GetUserTextureFavorites(database.MustGetDB(), userID, 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,
))
logger.MustGetLogger().Error("获取用户收藏列表失败", zap.Int64("user_id", userID), zap.Error(err))
RespondServerError(c, "获取收藏列表失败", 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))
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize))
}

View File

@@ -1,7 +1,6 @@
package handler
import (
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
"carrotskin/pkg/config"
@@ -9,7 +8,6 @@ import (
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -26,46 +24,22 @@ import (
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 获取用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := service.GetUserByID(userID)
if err != nil || user == nil {
loggerInstance.Error("获取用户信息失败",
zap.Int64("user_id", userID.(int64)),
logger.MustGetLogger().Error("获取用户信息失败",
zap.Int64("user_id", userID),
zap.Error(err),
)
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
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,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// UpdateUserProfile 更新用户信息
@@ -84,113 +58,62 @@ func GetUserProfile(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 获取用户
user, err := service.GetUserByID(userID.(int64))
user, err := service.GetUserByID(userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
// 处理密码修改
if req.NewPassword != "" {
// 如果提供了新密码,必须同时提供旧密码
if req.OldPassword == "" {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"修改密码需要提供原密码",
nil,
))
RespondBadRequest(c, "修改密码需要提供原密码", 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,
))
if err := service.ChangeUserPassword(userID, req.OldPassword, req.NewPassword); err != nil {
loggerInstance.Error("修改密码失败", zap.Int64("user_id", userID), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
loggerInstance.Info("用户修改密码成功",
zap.Int64("user_id", userID.(int64)),
)
loggerInstance.Info("用户修改密码成功", zap.Int64("user_id", userID))
}
// 更新头像
if req.Avatar != "" {
// 验证头像 URL 是否来自允许的域名
if err := service.ValidateAvatarURL(req.Avatar); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
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,
))
loggerInstance.Error("更新用户信息失败", zap.Int64("user_id", user.ID), zap.Error(err))
RespondServerError(c, "更新失败", err)
return
}
}
// 重新获取更新后的用户信息
updatedUser, err := service.GetUserByID(userID.(int64))
updatedUser, err := service.GetUserByID(userID)
if err != nil || updatedUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
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,
}))
RespondSuccess(c, UserToUserInfo(updatedUser))
}
// GenerateAvatarUploadURL 生成头像上传URL
@@ -205,52 +128,36 @@ func UpdateUserProfile(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateAvatarUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 调用UploadService生成预签名URL
storageClient := storage.MustGetClient()
cfg := *config.MustGetRustFSConfig()
result, err := service.GenerateAvatarUploadURL(c.Request.Context(), storageClient, cfg, userID.(int64), req.FileName)
result, err := service.GenerateAvatarUploadURL(c.Request.Context(), storageClient, cfg, userID, req.FileName)
if err != nil {
loggerInstance.Error("生成头像上传URL失败",
zap.Int64("user_id", userID.(int64)),
logger.MustGetLogger().Error("生成头像上传URL失败",
zap.Int64("user_id", userID),
zap.String("file_name", req.FileName),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateAvatarUploadURLResponse{
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
AvatarURL: result.FileURL,
ExpiresIn: 900, // 15分钟 = 900秒
}))
ExpiresIn: 900,
})
}
// UpdateAvatar 更新头像URL
@@ -265,65 +172,39 @@ func GenerateAvatarUploadURL(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
avatarURL := c.Query("avatar_url")
if avatarURL == "" {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"头像URL不能为空",
nil,
))
RespondBadRequest(c, "头像URL不能为空", nil)
return
}
// 更新头像
if err := service.UpdateUserAvatar(userID.(int64), avatarURL); err != nil {
loggerInstance.Error("更新头像失败",
zap.Int64("user_id", userID.(int64)),
if err := service.ValidateAvatarURL(avatarURL); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
if err := service.UpdateUserAvatar(userID, avatarURL); err != nil {
logger.MustGetLogger().Error("更新头像失败",
zap.Int64("user_id", userID),
zap.String("avatar_url", avatarURL),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"更新头像失败",
err,
))
RespondServerError(c, "更新头像失败", err)
return
}
// 获取更新后的用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := service.GetUserByID(userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
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,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// ChangeEmail 更换邮箱
@@ -340,79 +221,41 @@ func UpdateAvatar(c *gin.Context) {
// @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,
))
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.ChangeEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", 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,
))
loggerInstance.Warn("验证码验证失败", zap.String("new_email", req.NewEmail), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
// 更换邮箱
if err := service.ChangeUserEmail(userID.(int64), req.NewEmail); err != nil {
if err := service.ChangeUserEmail(userID, req.NewEmail); err != nil {
loggerInstance.Error("更换邮箱失败",
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.String("new_email", req.NewEmail),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 获取更新后的用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := service.GetUserByID(userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
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,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// ResetYggdrasilPassword 重置Yggdrasil密码
@@ -428,35 +271,19 @@ func ChangeEmail(c *gin.Context) {
// @Router /api/v1/user/yggdrasil-password/reset [post]
func ResetYggdrasilPassword(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
db := database.MustGetDB()
// 从上下文获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
return
}
userId := userID.(int64)
// 重置Yggdrasil密码
newPassword, err := service.ResetYggdrasilPassword(db, userId)
newPassword, err := service.ResetYggdrasilPassword(db, userID)
if err != nil {
loggerInstance.Error("[ERROR] 重置Yggdrasil密码失败", zap.Error(err), zap.Int64("userId", userId))
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"重置Yggdrasil密码失败",
nil,
))
loggerInstance.Error("重置Yggdrasil密码失败", zap.Error(err), zap.Int64("userId", userID))
RespondServerError(c, "重置Yggdrasil密码失败", nil)
return
}
loggerInstance.Info("[INFO] Yggdrasil密码重置成功", zap.Int64("userId", userId))
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"password": newPassword,
}))
loggerInstance.Info("Yggdrasil密码重置成功", zap.Int64("userId", userID))
RespondSuccess(c, gin.H{"password": newPassword})
}

View File

@@ -405,12 +405,8 @@ func SignOut(c *gin.Context) {
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 {
if err := service.VerifyPassword(db, request.Password, user.ID); err != nil {
loggerInstance.Warn("[WARN] 登出失败: 密码错误", zap.Any("用户ID:", user.ID))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrWrongPassword})
return

View File

@@ -7,18 +7,18 @@ import (
// AuditLog 审计日志模型
type AuditLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index" json:"user_id,omitempty"`
Action string `gorm:"column:action;type:varchar(100);not null;index" json:"action"`
ResourceType string `gorm:"column:resource_type;type:varchar(50);not null;index:idx_audit_logs_resource" json:"resource_type"`
ResourceID string `gorm:"column:resource_id;type:varchar(50);index:idx_audit_logs_resource" json:"resource_id,omitempty"`
UserID *int64 `gorm:"column:user_id;type:bigint;index:idx_audit_logs_user_created,priority:1" json:"user_id,omitempty"`
Action string `gorm:"column:action;type:varchar(100);not null;index:idx_audit_logs_action" json:"action"`
ResourceType string `gorm:"column:resource_type;type:varchar(50);not null;index:idx_audit_logs_resource,priority:1" json:"resource_type"`
ResourceID string `gorm:"column:resource_id;type:varchar(50);index:idx_audit_logs_resource,priority:2" json:"resource_id,omitempty"`
OldValues string `gorm:"column:old_values;type:jsonb" json:"old_values,omitempty"` // JSONB 格式
NewValues string `gorm:"column:new_values;type:jsonb" json:"new_values,omitempty"` // JSONB 格式
IPAddress string `gorm:"column:ip_address;type:inet;not null" json:"ip_address"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_audit_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_audit_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_audit_logs_user_created,priority:2,sort:desc;index:idx_audit_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:SET NULL" json:"user,omitempty"`
}
// TableName 指定表名
@@ -29,13 +29,13 @@ func (AuditLog) TableName() string {
// CasbinRule Casbin 权限规则模型
type CasbinRule struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
PType string `gorm:"column:ptype;type:varchar(100);not null;index;uniqueIndex:uk_casbin_rule" json:"ptype"`
V0 string `gorm:"column:v0;type:varchar(100);not null;default:'';index;uniqueIndex:uk_casbin_rule" json:"v0"`
V1 string `gorm:"column:v1;type:varchar(100);not null;default:'';index;uniqueIndex:uk_casbin_rule" json:"v1"`
V2 string `gorm:"column:v2;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v2"`
V3 string `gorm:"column:v3;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v3"`
V4 string `gorm:"column:v4;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v4"`
V5 string `gorm:"column:v5;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v5"`
PType string `gorm:"column:ptype;type:varchar(100);not null;index:idx_casbin_ptype;uniqueIndex:uk_casbin_rule,priority:1" json:"ptype"`
V0 string `gorm:"column:v0;type:varchar(100);not null;default:'';index:idx_casbin_v0;uniqueIndex:uk_casbin_rule,priority:2" json:"v0"`
V1 string `gorm:"column:v1;type:varchar(100);not null;default:'';index:idx_casbin_v1;uniqueIndex:uk_casbin_rule,priority:3" json:"v1"`
V2 string `gorm:"column:v2;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:4" json:"v2"`
V3 string `gorm:"column:v3;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:5" json:"v3"`
V4 string `gorm:"column:v4;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:6" json:"v4"`
V5 string `gorm:"column:v5;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:7" json:"v5"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
}

View File

@@ -7,20 +7,20 @@ import (
// Profile Minecraft 档案模型
type Profile struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint" json:"cape_id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1;index:idx_profiles_user_active,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
IsActive bool `gorm:"column:is_active;not null;default:true;index" json:"is_active"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
IsActive bool `gorm:"column:is_active;not null;default:true;index:idx_profiles_user_active,priority:2" json:"is_active"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Skin *Texture `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
Cape *Texture `gorm:"foreignKey:CapeID" json:"cape,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Skin *Texture `gorm:"foreignKey:SkinID;constraint:OnDelete:SET NULL" json:"skin,omitempty"`
Cape *Texture `gorm:"foreignKey:CapeID;constraint:OnDelete:SET NULL" json:"cape,omitempty"`
}
// TableName 指定表名

View File

@@ -15,23 +15,23 @@ const (
// Texture 材质模型
type Texture struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UploaderID int64 `gorm:"column:uploader_id;not null;index" json:"uploader_id"`
UploaderID int64 `gorm:"column:uploader_id;not null;index:idx_textures_uploader_status,priority:1;index:idx_textures_uploader_created,priority:1" json:"uploader_id"`
Name string `gorm:"column:name;type:varchar(100);not null;default:''" json:"name"`
Description string `gorm:"column:description;type:text" json:"description,omitempty"`
Type TextureType `gorm:"column:type;type:varchar(50);not null" json:"type"` // SKIN, CAPE
Type TextureType `gorm:"column:type;type:varchar(50);not null;index:idx_textures_public_type_status,priority:2" json:"type"` // SKIN, CAPE
URL string `gorm:"column:url;type:varchar(255);not null" json:"url"`
Hash string `gorm:"column:hash;type:varchar(64);not null;uniqueIndex" json:"hash"` // SHA-256
Hash string `gorm:"column:hash;type:varchar(64);not null;uniqueIndex:idx_textures_hash" json:"hash"` // SHA-256
Size int `gorm:"column:size;type:integer;not null;default:0" json:"size"`
IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status" json:"is_public"`
IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status,priority:1" json:"is_public"`
DownloadCount int `gorm:"column:download_count;type:integer;not null;default:0;index:idx_textures_download_count,sort:desc" json:"download_count"`
FavoriteCount int `gorm:"column:favorite_count;type:integer;not null;default:0;index:idx_textures_favorite_count,sort:desc" json:"favorite_count"`
IsSlim bool `gorm:"column:is_slim;not null;default:false" json:"is_slim"` // Alex(细) or Steve(粗)
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status" json:"status"` // 1:正常, 0:审核中, -1:已删除
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status,priority:3;index:idx_textures_uploader_status,priority:2" json:"status"` // 1:正常, 0:审核中, -1:已删除
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_textures_uploader_created,priority:2,sort:desc;index:idx_textures_created_at,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
Uploader *User `gorm:"foreignKey:UploaderID" json:"uploader,omitempty"`
Uploader *User `gorm:"foreignKey:UploaderID;constraint:OnDelete:CASCADE" json:"uploader,omitempty"`
}
// TableName 指定表名
@@ -42,13 +42,13 @@ func (Texture) TableName() string {
// UserTextureFavorite 用户材质收藏
type UserTextureFavorite struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index;uniqueIndex:uk_user_texture" json:"user_id"`
TextureID int64 `gorm:"column:texture_id;not null;index;uniqueIndex:uk_user_texture" json:"texture_id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index" json:"created_at"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:uk_user_texture,priority:1;index:idx_favorites_user_created,priority:1" json:"user_id"`
TextureID int64 `gorm:"column:texture_id;not null;uniqueIndex:uk_user_texture,priority:2;index:idx_favorites_texture_id" json:"texture_id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_favorites_user_created,priority:2,sort:desc;index:idx_favorites_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID;constraint:OnDelete:CASCADE" json:"texture,omitempty"`
}
// TableName 指定表名
@@ -59,15 +59,15 @@ func (UserTextureFavorite) TableName() string {
// TextureDownloadLog 材质下载记录
type TextureDownloadLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TextureID int64 `gorm:"column:texture_id;not null;index" json:"texture_id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index" json:"user_id,omitempty"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index" json:"ip_address"`
TextureID int64 `gorm:"column:texture_id;not null;index:idx_download_logs_texture_created,priority:1" json:"texture_id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index:idx_download_logs_user_id" json:"user_id,omitempty"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_download_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_download_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_download_logs_texture_created,priority:2,sort:desc;index:idx_download_logs_created_at,sort:desc" json:"created_at"`
// 关联
Texture *Texture `gorm:"foreignKey:TextureID" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID;constraint:OnDelete:CASCADE" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:SET NULL" json:"user,omitempty"`
}
// TableName 指定表名

View File

@@ -2,13 +2,19 @@ package model
import "time"
// Token Yggdrasil 认证令牌模型
type Token struct {
AccessToken string `json:"_id"`
UserID int64 `json:"user_id"`
ClientToken string `json:"client_token"`
ProfileId string `json:"profile_id"`
Usable bool `json:"usable"`
IssueDate time.Time `json:"issue_date"`
AccessToken string `gorm:"column:access_token;type:varchar(64);primaryKey" json:"access_token"`
UserID int64 `gorm:"column:user_id;not null;index:idx_tokens_user_id" json:"user_id"`
ClientToken string `gorm:"column:client_token;type:varchar(64);not null;index:idx_tokens_client_token" json:"client_token"`
ProfileId string `gorm:"column:profile_id;type:varchar(36);not null;index:idx_tokens_profile_id" json:"profile_id"`
Usable bool `gorm:"column:usable;not null;default:true;index:idx_tokens_usable" json:"usable"`
IssueDate time.Time `gorm:"column:issue_date;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_tokens_issue_date,sort:desc" json:"issue_date"`
// 关联
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Profile *Profile `gorm:"foreignKey:ProfileId;references:UUID;constraint:OnDelete:CASCADE" json:"profile,omitempty"`
}
func (Token) TableName() string { return "token" }
// TableName 指定表名
func (Token) TableName() string { return "tokens" }

View File

@@ -9,16 +9,16 @@ import (
// User 用户模型
type User struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(255);not null;uniqueIndex" json:"username"`
Username string `gorm:"column:username;type:varchar(255);not null;uniqueIndex:idx_user_username_status,priority:1" json:"username"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` // 密码不返回给前端
Email string `gorm:"column:email;type:varchar(255);not null;uniqueIndex" json:"email"`
Email string `gorm:"column:email;type:varchar(255);not null;uniqueIndex:idx_user_email_status,priority:1" json:"email"`
Avatar string `gorm:"column:avatar;type:varchar(255);not null;default:''" json:"avatar"`
Points int `gorm:"column:points;type:integer;not null;default:0" json:"points"`
Role string `gorm:"column:role;type:varchar(50);not null;default:'user'" json:"role"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1" json:"status"` // 1:正常, 0:禁用, -1:删除
Properties *datatypes.JSON `gorm:"column:properties;type:jsonb" json:"properties,omitempty"` // JSON数据存储为PostgreSQL的JSONB类型
LastLoginAt *time.Time `gorm:"column:last_login_at;type:timestamp" json:"last_login_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
Points int `gorm:"column:points;type:integer;not null;default:0;index:idx_user_points,sort:desc" json:"points"`
Role string `gorm:"column:role;type:varchar(50);not null;default:'user';index:idx_user_role_status,priority:1" json:"role"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_user_username_status,priority:2;index:idx_user_email_status,priority:2;index:idx_user_role_status,priority:2" json:"status"` // 1:正常, 0:禁用, -1:删除
Properties *datatypes.JSON `gorm:"column:properties;type:jsonb" json:"properties,omitempty"` // JSON数据存储为PostgreSQL的JSONB类型
LastLoginAt *time.Time `gorm:"column:last_login_at;type:timestamp;index:idx_user_last_login,sort:desc" json:"last_login_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_user_created_at,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
@@ -30,20 +30,20 @@ func (User) TableName() string {
// UserPointLog 用户积分变更记录
type UserPointLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
ChangeType string `gorm:"column:change_type;type:varchar(50);not null" json:"change_type"` // EARN, SPEND, ADMIN_ADJUST
UserID int64 `gorm:"column:user_id;not null;index:idx_point_logs_user_created,priority:1" json:"user_id"`
ChangeType string `gorm:"column:change_type;type:varchar(50);not null;index:idx_point_logs_change_type" json:"change_type"` // EARN, SPEND, ADMIN_ADJUST
Amount int `gorm:"column:amount;type:integer;not null" json:"amount"`
BalanceBefore int `gorm:"column:balance_before;type:integer;not null" json:"balance_before"`
BalanceAfter int `gorm:"column:balance_after;type:integer;not null" json:"balance_after"`
Reason string `gorm:"column:reason;type:varchar(255);not null" json:"reason"`
ReferenceType string `gorm:"column:reference_type;type:varchar(50)" json:"reference_type,omitempty"`
ReferenceID *int64 `gorm:"column:reference_id;type:bigint" json:"reference_id,omitempty"`
OperatorID *int64 `gorm:"column:operator_id;type:bigint" json:"operator_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_point_logs_created_at,sort:desc" json:"created_at"`
OperatorID *int64 `gorm:"column:operator_id;type:bigint;index" json:"operator_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_point_logs_user_created,priority:2,sort:desc;index:idx_point_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID;constraint:OnDelete:SET NULL" json:"operator,omitempty"`
}
// TableName 指定表名
@@ -54,16 +54,16 @@ func (UserPointLog) TableName() string {
// UserLoginLog 用户登录日志
type UserLoginLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index" json:"ip_address"`
UserID int64 `gorm:"column:user_id;not null;index:idx_login_logs_user_created,priority:1" json:"user_id"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_login_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
LoginMethod string `gorm:"column:login_method;type:varchar(50);not null;default:'PASSWORD'" json:"login_method"`
IsSuccess bool `gorm:"column:is_success;not null;index" json:"is_success"`
IsSuccess bool `gorm:"column:is_success;not null;index:idx_login_logs_success" json:"is_success"`
FailureReason string `gorm:"column:failure_reason;type:varchar(255)" json:"failure_reason,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_login_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_login_logs_user_created,priority:2,sort:desc;index:idx_login_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
}
// TableName 指定表名

View File

@@ -1,10 +1,12 @@
package model
import (
"crypto/rand"
"fmt"
"math/big"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"math/rand"
"time"
)
// 定义随机字符集
@@ -13,36 +15,47 @@ const passwordChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234
// Yggdrasil ygg密码与用户id绑定
type Yggdrasil struct {
ID int64 `gorm:"column:id;primaryKey;not null" json:"id"`
Password string `gorm:"column:password;not null" json:"password"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` // 加密后的密码,不返回给前端
// 关联 - Yggdrasil的ID引用User的ID但不自动创建外键约束避免循环依赖
User *User `gorm:"foreignKey:ID;references:ID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE" json:"user,omitempty"`
}
func (Yggdrasil) TableName() string { return "Yggdrasil" }
func (Yggdrasil) TableName() string { return "yggdrasil" }
// AfterCreate User创建后自动同步生成GeneratePassword记录
// AfterCreate User创建后自动同步生成Yggdrasil密码记录
func (u *User) AfterCreate(tx *gorm.DB) error {
randomPwd := GenerateRandomPassword(16)
// 生成随机明文密码
plainPassword := GenerateRandomPassword(16)
// 创建GeneratePassword记录
gp := Yggdrasil{
ID: u.ID, // 关联User的ID
Password: randomPwd, // 16位随机密码
// 使用 bcrypt 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码加密失败: %w", err)
}
if err := tx.Create(&gp).Error; err != nil {
// 若同步失败,可记录日志或回滚事务(根据业务需求处理)
return fmt.Errorf("同步生成密码失败: %w", err)
// 创建Yggdrasil记录存储加密后的密码
ygg := Yggdrasil{
ID: u.ID,
Password: string(hashedPassword),
}
if err := tx.Create(&ygg).Error; err != nil {
return fmt.Errorf("同步生成Yggdrasil密码失败: %w", err)
}
return nil
}
// GenerateRandomPassword 生成指定长度的随机字符串
// GenerateRandomPassword 生成指定长度的安全随机字符串
func GenerateRandomPassword(length int) string {
rand.Seed(time.Now().UnixNano()) // 初始化随机数种子
b := make([]byte, length)
for i := range b {
b[i] = passwordChars[rand.Intn(len(passwordChars))]
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars))))
if err != nil {
// 如果安全随机数生成失败,使用固定值(极端情况下的降级处理)
b[i] = passwordChars[0]
continue
}
b[i] = passwordChars[num.Int64()]
}
return string(b)
}

View File

@@ -0,0 +1,82 @@
package repository
import (
"carrotskin/pkg/database"
"errors"
"gorm.io/gorm"
)
// getDB 获取数据库连接(内部使用)
func getDB() *gorm.DB {
return database.MustGetDB()
}
// IsNotFound 检查是否为记录未找到错误
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// HandleNotFound 处理记录未找到的情况,未找到时返回 nil, nil
func HandleNotFound[T any](result *T, err error) (*T, error) {
if err != nil {
if IsNotFound(err) {
return nil, nil
}
return nil, err
}
return result, nil
}
// Paginate 创建分页查询
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// PaginatedQuery 执行分页查询,返回列表和总数
func PaginatedQuery[T any](
baseQuery *gorm.DB,
page, pageSize int,
orderBy string,
preloads ...string,
) ([]T, int64, error) {
var items []T
var total int64
// 获取总数
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
query := baseQuery.Scopes(Paginate(page, pageSize))
// 添加排序
if orderBy != "" {
query = query.Order(orderBy)
}
// 添加预加载
for _, preload := range preloads {
query = query.Preload(preload)
}
if err := query.Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}

View File

@@ -2,7 +2,6 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"errors"
"fmt"
@@ -12,15 +11,13 @@ import (
// CreateProfile 创建档案
func CreateProfile(profile *model.Profile) error {
db := database.MustGetDB()
return db.Create(profile).Error
return getDB().Create(profile).Error
}
// FindProfileByUUID 根据UUID查找档案
func FindProfileByUUID(uuid string) (*model.Profile, error) {
db := database.MustGetDB()
var profile model.Profile
err := db.Where("uuid = ?", uuid).
err := getDB().Where("uuid = ?", uuid).
Preload("Skin").
Preload("Cape").
First(&profile).Error
@@ -32,9 +29,8 @@ func FindProfileByUUID(uuid string) (*model.Profile, error) {
// FindProfileByName 根据角色名查找档案
func FindProfileByName(name string) (*model.Profile, error) {
db := database.MustGetDB()
var profile model.Profile
err := db.Where("name = ?", name).First(&profile).Error
err := getDB().Where("name = ?", name).First(&profile).Error
if err != nil {
return nil, err
}
@@ -43,44 +39,36 @@ func FindProfileByName(name string) (*model.Profile, error) {
// FindProfilesByUserID 获取用户的所有档案
func FindProfilesByUserID(userID int64) ([]*model.Profile, error) {
db := database.MustGetDB()
var profiles []*model.Profile
err := db.Where("user_id = ?", userID).
err := getDB().Where("user_id = ?", userID).
Preload("Skin").
Preload("Cape").
Order("created_at DESC").
Find(&profiles).Error
if err != nil {
return nil, err
}
return profiles, nil
return profiles, err
}
// UpdateProfile 更新档案
func UpdateProfile(profile *model.Profile) error {
db := database.MustGetDB()
return db.Save(profile).Error
return getDB().Save(profile).Error
}
// UpdateProfileFields 更新指定字段
func UpdateProfileFields(uuid string, updates map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.Profile{}).
return getDB().Model(&model.Profile{}).
Where("uuid = ?", uuid).
Updates(updates).Error
}
// DeleteProfile 删除档案
func DeleteProfile(uuid string) error {
db := database.MustGetDB()
return db.Where("uuid = ?", uuid).Delete(&model.Profile{}).Error
return getDB().Where("uuid = ?", uuid).Delete(&model.Profile{}).Error
}
// CountProfilesByUserID 统计用户的档案数量
func CountProfilesByUserID(userID int64) (int64, error) {
db := database.MustGetDB()
var count int64
err := db.Model(&model.Profile{}).
err := getDB().Model(&model.Profile{}).
Where("user_id = ?", userID).
Count(&count).Error
return count, err
@@ -88,30 +76,22 @@ func CountProfilesByUserID(userID int64) (int64, error) {
// SetActiveProfile 设置档案为活跃状态(同时将用户的其他档案设置为非活跃)
func SetActiveProfile(uuid string, userID int64) error {
db := database.MustGetDB()
return db.Transaction(func(tx *gorm.DB) error {
// 将用户的所有档案设置为非活跃
return getDB().Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Profile{}).
Where("user_id = ?", userID).
Update("is_active", false).Error; err != nil {
return err
}
// 将指定档案设置为活跃
if err := tx.Model(&model.Profile{}).
return tx.Model(&model.Profile{}).
Where("uuid = ? AND user_id = ?", uuid, userID).
Update("is_active", true).Error; err != nil {
return err
}
return nil
Update("is_active", true).Error
})
}
// UpdateProfileLastUsedAt 更新最后使用时间
func UpdateProfileLastUsedAt(uuid string) error {
db := database.MustGetDB()
return db.Model(&model.Profile{}).
return getDB().Model(&model.Profile{}).
Where("uuid = ?", uuid).
Update("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error
}
@@ -122,53 +102,40 @@ func FindOneProfileByUserID(userID int64) (*model.Profile, error) {
if err != nil {
return nil, err
}
profile := profiles[0]
return profile, nil
if len(profiles) == 0 {
return nil, errors.New("未找到角色")
}
return profiles[0], nil
}
func GetProfilesByNames(names []string) ([]*model.Profile, error) {
db := database.MustGetDB()
var profiles []*model.Profile
err := db.Where("name in (?)", names).Find(&profiles).Error
if err != nil {
return nil, err
}
return profiles, nil
err := getDB().Where("name in (?)", names).Find(&profiles).Error
return profiles, err
}
func GetProfileKeyPair(profileId string) (*model.KeyPair, error) {
db := database.MustGetDB()
// 1. 参数校验(保持原逻辑)
if profileId == "" {
return nil, errors.New("参数不能为空")
}
// 2. GORM 查询:只查询 key_pair 字段(对应原 mongo 投影)
var profile *model.Profile
// 条件id = profileIdPostgreSQL 主键),只选择 key_pair 字段
result := db.WithContext(context.Background()).
Select("key_pair"). // 只查询需要的字段(投影)
Where("id = ?", profileId). // 查询条件GORM 自动处理占位符,避免 SQL 注入)
First(&profile) // 查单条记录
var profile model.Profile
result := getDB().WithContext(context.Background()).
Select("key_pair").
Where("id = ?", profileId).
First(&profile)
// 3. 错误处理(适配 GORM 错误类型)
if result.Error != nil {
// 空结果判断(对应原 mongo.ErrNoDocuments / pgx.ErrNoRows
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
if IsNotFound(result.Error) {
return nil, errors.New("key pair未找到")
}
// 保持原错误封装格式
return nil, fmt.Errorf("获取key pair失败: %w", result.Error)
}
// 4. JSONB 反序列化为 model.KeyPair
keyPair := &model.KeyPair{}
return keyPair, nil
return &model.KeyPair{}, nil
}
func UpdateProfileKeyPair(profileId string, keyPair *model.KeyPair) error {
db := database.MustGetDB()
// 仅保留最必要的入参校验(避免无效数据库请求)
if profileId == "" {
return errors.New("profileId 不能为空")
}
@@ -176,24 +143,18 @@ func UpdateProfileKeyPair(profileId string, keyPair *model.KeyPair) error {
return errors.New("keyPair 不能为 nil")
}
// 事务内执行核心更新(保证原子性,出错自动回滚)
return db.Transaction(func(tx *gorm.DB) error {
// 核心更新逻辑:按 profileId 匹配,直接更新 key_pair 相关字段
return getDB().Transaction(func(tx *gorm.DB) error {
result := tx.WithContext(context.Background()).
Table("profiles"). // 目标表名(与 PostgreSQL 表一致)
Where("id = ?", profileId). // 更新条件profileId 匹配
// 直接映射字段(无需序列化,依赖 GORM 自动字段匹配)
Table("profiles").
Where("id = ?", profileId).
UpdateColumns(map[string]interface{}{
"private_key": keyPair.PrivateKey, // 数据库 private_key 字段
"public_key": keyPair.PublicKey, // 数据库 public_key 字段
// 若 key_pair 是单个字段(非拆分),替换为:"key_pair": keyPair
"private_key": keyPair.PrivateKey,
"public_key": keyPair.PublicKey,
})
// 仅处理数据库层面的致命错误
if result.Error != nil {
return fmt.Errorf("更新 keyPair 失败: %w", result.Error)
}
return nil
})
}

View File

@@ -2,56 +2,35 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"errors"
"gorm.io/gorm"
)
// GetSystemConfigByKey 根据键获取配置
func GetSystemConfigByKey(key string) (*model.SystemConfig, error) {
db := database.MustGetDB()
var config model.SystemConfig
err := db.Where("key = ?", key).First(&config).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &config, nil
err := getDB().Where("key = ?", key).First(&config).Error
return HandleNotFound(&config, err)
}
// GetPublicSystemConfigs 获取所有公开配置
func GetPublicSystemConfigs() ([]model.SystemConfig, error) {
db := database.MustGetDB()
var configs []model.SystemConfig
err := db.Where("is_public = ?", true).Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
err := getDB().Where("is_public = ?", true).Find(&configs).Error
return configs, err
}
// GetAllSystemConfigs 获取所有配置(管理员用)
func GetAllSystemConfigs() ([]model.SystemConfig, error) {
db := database.MustGetDB()
var configs []model.SystemConfig
err := db.Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
err := getDB().Find(&configs).Error
return configs, err
}
// UpdateSystemConfig 更新配置
func UpdateSystemConfig(config *model.SystemConfig) error {
db := database.MustGetDB()
return db.Save(config).Error
return getDB().Save(config).Error
}
// UpdateSystemConfigValue 更新配置值
func UpdateSystemConfigValue(key, value string) error {
db := database.MustGetDB()
return db.Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
return getDB().Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
}

View File

@@ -2,63 +2,44 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"gorm.io/gorm"
)
// CreateTexture 创建材质
func CreateTexture(texture *model.Texture) error {
db := database.MustGetDB()
return db.Create(texture).Error
return getDB().Create(texture).Error
}
// FindTextureByID 根据ID查找材质
func FindTextureByID(id int64) (*model.Texture, error) {
db := database.MustGetDB()
var texture model.Texture
err := db.Preload("Uploader").First(&texture, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &texture, nil
err := getDB().Preload("Uploader").First(&texture, id).Error
return HandleNotFound(&texture, err)
}
// FindTextureByHash 根据Hash查找材质
func FindTextureByHash(hash string) (*model.Texture, error) {
db := database.MustGetDB()
var texture model.Texture
err := db.Where("hash = ?", hash).First(&texture).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &texture, nil
err := getDB().Where("hash = ?", hash).First(&texture).Error
return HandleNotFound(&texture, err)
}
// FindTexturesByUploaderID 根据上传者ID查找材质列表
func FindTexturesByUploaderID(uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
db := getDB()
var textures []*model.Texture
var total int64
query := db.Model(&model.Texture{}).Where("uploader_id = ? AND status != -1", uploaderID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -70,38 +51,29 @@ func FindTexturesByUploaderID(uploaderID int64, page, pageSize int) ([]*model.Te
// SearchTextures 搜索材质
func SearchTextures(keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
db := getDB()
var textures []*model.Texture
var total int64
query := db.Model(&model.Texture{}).Where("status = 1")
// 公开筛选
if publicOnly {
query = query.Where("is_public = ?", true)
}
// 类型筛选
if textureType != "" {
query = query.Where("type = ?", textureType)
}
// 关键词搜索
if keyword != "" {
query = query.Where("name LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -113,86 +85,72 @@ func SearchTextures(keyword string, textureType model.TextureType, publicOnly bo
// UpdateTexture 更新材质
func UpdateTexture(texture *model.Texture) error {
db := database.MustGetDB()
return db.Save(texture).Error
return getDB().Save(texture).Error
}
// UpdateTextureFields 更新材质指定字段
func UpdateTextureFields(id int64, fields map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).Updates(fields).Error
return getDB().Model(&model.Texture{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteTexture 删除材质(软删除)
func DeleteTexture(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).Update("status", -1).Error
return getDB().Model(&model.Texture{}).Where("id = ?", id).Update("status", -1).Error
}
// IncrementTextureDownloadCount 增加下载次数
func IncrementTextureDownloadCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
return getDB().Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error
}
// IncrementTextureFavoriteCount 增加收藏次数
func IncrementTextureFavoriteCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
return getDB().Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1)).Error
}
// DecrementTextureFavoriteCount 减少收藏次数
func DecrementTextureFavoriteCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
return getDB().Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1)).Error
}
// CreateTextureDownloadLog 创建下载日志
func CreateTextureDownloadLog(log *model.TextureDownloadLog) error {
db := database.MustGetDB()
return db.Create(log).Error
return getDB().Create(log).Error
}
// IsTextureFavorited 检查是否已收藏
func IsTextureFavorited(userID, textureID int64) (bool, error) {
db := database.MustGetDB()
var count int64
err := db.Model(&model.UserTextureFavorite{}).
err := getDB().Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
return count > 0, err
}
// AddTextureFavorite 添加收藏
func AddTextureFavorite(userID, textureID int64) error {
db := database.MustGetDB()
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
return db.Create(favorite).Error
return getDB().Create(favorite).Error
}
// RemoveTextureFavorite 取消收藏
func RemoveTextureFavorite(userID, textureID int64) error {
db := database.MustGetDB()
return db.Where("user_id = ? AND texture_id = ?", userID, textureID).
return getDB().Where("user_id = ? AND texture_id = ?", userID, textureID).
Delete(&model.UserTextureFavorite{}).Error
}
// GetUserTextureFavorites 获取用户收藏的材质列表
func GetUserTextureFavorites(userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
db := getDB()
var textures []*model.Texture
var total int64
// 子查询获取收藏的材质ID
subQuery := db.Model(&model.UserTextureFavorite{}).
Select("texture_id").
Where("user_id = ?", userID)
@@ -200,17 +158,13 @@ func GetUserTextureFavorites(userID int64, page, pageSize int) ([]*model.Texture
query := db.Model(&model.Texture{}).
Where("id IN (?) AND status = 1", subQuery)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -222,9 +176,8 @@ func GetUserTextureFavorites(userID int64, page, pageSize int) ([]*model.Texture
// CountTexturesByUploaderID 统计用户上传的材质数量
func CountTexturesByUploaderID(uploaderID int64) (int64, error) {
db := database.MustGetDB()
var count int64
err := db.Model(&model.Texture{}).
err := getDB().Model(&model.Texture{}).
Where("uploader_id = ? AND status != -1", uploaderID).
Count(&count).Error
return count, err

View File

@@ -2,48 +2,38 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
)
func CreateToken(token *model.Token) error {
db := database.MustGetDB()
return db.Create(token).Error
return getDB().Create(token).Error
}
func GetTokensByUserId(userId int64) ([]*model.Token, error) {
db := database.MustGetDB()
tokens := make([]*model.Token, 0)
err := db.Where("user_id = ?", userId).Find(&tokens).Error
if err != nil {
return nil, err
}
return tokens, nil
var tokens []*model.Token
err := getDB().Where("user_id = ?", userId).Find(&tokens).Error
return tokens, err
}
func BatchDeleteTokens(tokensToDelete []string) (int64, error) {
db := database.MustGetDB()
if len(tokensToDelete) == 0 {
return 0, nil // 无需要删除的令牌,直接返回
return 0, nil
}
result := db.Where("access_token IN ?", tokensToDelete).Delete(&model.Token{})
result := getDB().Where("access_token IN ?", tokensToDelete).Delete(&model.Token{})
return result.RowsAffected, result.Error
}
func FindTokenByID(accessToken string) (*model.Token, error) {
db := database.MustGetDB()
var tokens []*model.Token
err := db.Where("_id = ?", accessToken).Find(&tokens).Error
var token model.Token
err := getDB().Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return nil, err
}
return tokens[0], nil
return &token, nil
}
func GetUUIDByAccessToken(accessToken string) (string, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
err := getDB().Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return "", err
}
@@ -51,9 +41,8 @@ func GetUUIDByAccessToken(accessToken string) (string, error) {
}
func GetUserIDByAccessToken(accessToken string) (int64, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
err := getDB().Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return 0, err
}
@@ -61,9 +50,8 @@ func GetUserIDByAccessToken(accessToken string) (int64, error) {
}
func GetTokenByAccessToken(accessToken string) (*model.Token, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
err := getDB().Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return nil, err
}
@@ -71,19 +59,9 @@ func GetTokenByAccessToken(accessToken string) (*model.Token, error) {
}
func DeleteTokenByAccessToken(accessToken string) error {
db := database.MustGetDB()
err := db.Where("access_token = ?", accessToken).Delete(&model.Token{}).Error
if err != nil {
return err
}
return nil
return getDB().Where("access_token = ?", accessToken).Delete(&model.Token{}).Error
}
func DeleteTokenByUserId(userId int64) error {
db := database.MustGetDB()
err := db.Where("user_id = ?", userId).Delete(&model.Token{}).Error
if err != nil {
return err
}
return nil
return getDB().Where("user_id = ?", userId).Delete(&model.Token{}).Error
}

View File

@@ -2,7 +2,6 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"errors"
"gorm.io/gorm"
@@ -10,87 +9,58 @@ import (
// CreateUser 创建用户
func CreateUser(user *model.User) error {
db := database.MustGetDB()
return db.Create(user).Error
return getDB().Create(user).Error
}
// FindUserByID 根据ID查找用户
func FindUserByID(id int64) (*model.User, error) {
db := database.MustGetDB()
var user model.User
err := db.Where("id = ? AND status != -1", id).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
err := getDB().Where("id = ? AND status != -1", id).First(&user).Error
return HandleNotFound(&user, err)
}
// FindUserByUsername 根据用户名查找用户
func FindUserByUsername(username string) (*model.User, error) {
db := database.MustGetDB()
var user model.User
err := db.Where("username = ? AND status != -1", username).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
err := getDB().Where("username = ? AND status != -1", username).First(&user).Error
return HandleNotFound(&user, err)
}
// FindUserByEmail 根据邮箱查找用户
func FindUserByEmail(email string) (*model.User, error) {
db := database.MustGetDB()
var user model.User
err := db.Where("email = ? AND status != -1", email).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
err := getDB().Where("email = ? AND status != -1", email).First(&user).Error
return HandleNotFound(&user, err)
}
// UpdateUser 更新用户
func UpdateUser(user *model.User) error {
db := database.MustGetDB()
return db.Save(user).Error
return getDB().Save(user).Error
}
// UpdateUserFields 更新指定字段
func UpdateUserFields(id int64, fields map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", id).Updates(fields).Error
return getDB().Model(&model.User{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteUser 软删除用户
func DeleteUser(id int64) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", id).Update("status", -1).Error
return getDB().Model(&model.User{}).Where("id = ?", id).Update("status", -1).Error
}
// CreateLoginLog 创建登录日志
func CreateLoginLog(log *model.UserLoginLog) error {
db := database.MustGetDB()
return db.Create(log).Error
return getDB().Create(log).Error
}
// CreatePointLog 创建积分日志
func CreatePointLog(log *model.UserPointLog) error {
db := database.MustGetDB()
return db.Create(log).Error
return getDB().Create(log).Error
}
// UpdateUserPoints 更新用户积分(事务)
func UpdateUserPoints(userID int64, amount int, changeType, reason string) error {
db := database.MustGetDB()
return db.Transaction(func(tx *gorm.DB) error {
// 获取当前用户积分
return getDB().Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Where("id = ?", userID).First(&user).Error; err != nil {
return err
@@ -99,17 +69,14 @@ func UpdateUserPoints(userID int64, amount int, changeType, reason string) error
balanceBefore := user.Points
balanceAfter := balanceBefore + amount
// 检查积分是否足够
if balanceAfter < 0 {
return errors.New("积分不足")
}
// 更新用户积分
if err := tx.Model(&user).Update("points", balanceAfter).Error; err != nil {
return err
}
// 创建积分日志
log := &model.UserPointLog{
UserID: userID,
ChangeType: changeType,
@@ -125,12 +92,10 @@ func UpdateUserPoints(userID int64, amount int, changeType, reason string) error
// UpdateUserAvatar 更新用户头像
func UpdateUserAvatar(userID int64, avatarURL string) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", avatarURL).Error
return getDB().Model(&model.User{}).Where("id = ?", userID).Update("avatar", avatarURL).Error
}
// UpdateUserEmail 更新用户邮箱
func UpdateUserEmail(userID int64, email string) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", userID).Update("email", email).Error
return getDB().Model(&model.User{}).Where("id = ?", userID).Update("email", email).Error
}

View File

@@ -2,13 +2,11 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
)
func GetYggdrasilPasswordById(Id int64) (string, error) {
db := database.MustGetDB()
func GetYggdrasilPasswordById(id int64) (string, error) {
var yggdrasil model.Yggdrasil
err := db.Where("id = ?", Id).First(&yggdrasil).Error
err := getDB().Where("id = ?", id).First(&yggdrasil).Error
if err != nil {
return "", err
}
@@ -17,6 +15,5 @@ func GetYggdrasilPasswordById(Id int64) (string, error) {
// ResetYggdrasilPassword 重置Yggdrasil密码
func ResetYggdrasilPassword(userId int64, newPassword string) error {
db := database.MustGetDB()
return db.Model(&model.Yggdrasil{}).Where("id = ?", userId).Update("password", newPassword).Error
return getDB().Model(&model.Yggdrasil{}).Where("id = ?", userId).Update("password", newPassword).Error
}

105
internal/service/helpers.go Normal file
View File

@@ -0,0 +1,105 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"errors"
"fmt"
"gorm.io/gorm"
)
// 通用错误
var (
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNoPermission = errors.New("无权操作此档案")
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrUserNotFound = errors.New("用户不存在")
)
// NormalizePagination 规范化分页参数
func NormalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
// GetProfileWithPermissionCheck 获取档案并验证权限
// 返回档案,如果不存在或无权限则返回相应错误
func GetProfileWithPermissionCheck(uuid string, userID int64) (*model.Profile, error) {
profile, err := repository.FindProfileByUUID(uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return nil, ErrProfileNoPermission
}
return profile, nil
}
// GetTextureWithPermissionCheck 获取材质并验证权限
// 返回材质,如果不存在或无权限则返回相应错误
func GetTextureWithPermissionCheck(textureID, userID int64) (*model.Texture, error) {
texture, err := repository.FindTextureByID(textureID)
if err != nil {
return nil, err
}
if texture == nil {
return nil, ErrTextureNotFound
}
if texture.UploaderID != userID {
return nil, ErrTextureNoPermission
}
return texture, nil
}
// EnsureTextureExists 确保材质存在
func EnsureTextureExists(textureID int64) (*model.Texture, error) {
texture, err := repository.FindTextureByID(textureID)
if err != nil {
return nil, err
}
if texture == nil {
return nil, ErrTextureNotFound
}
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
return texture, nil
}
// EnsureUserExists 确保用户存在
func EnsureUserExists(userID int64) (*model.User, error) {
user, err := repository.FindUserByID(userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
// WrapError 包装错误,添加上下文信息
func WrapError(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"gorm.io/gorm"
@@ -16,53 +17,47 @@ import (
// CreateProfile 创建档案
func CreateProfile(db *gorm.DB, userID int64, name string) (*model.Profile, error) {
// 1. 验证用户存在
user, err := repository.FindUserByID(userID)
// 验证用户存在
user, err := EnsureUserExists(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户不存在")
}
return nil, fmt.Errorf("查询用户失败: %w", err)
return nil, err
}
if user.Status != 1 {
return nil, fmt.Errorf("用户状态异常")
}
// 2. 检查角色名是否已存在
// 检查角色名是否已存在
existingName, err := repository.FindProfileByName(name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
return nil, WrapError(err, "查询角色名失败")
}
if existingName != nil {
return nil, fmt.Errorf("角色名已被使用")
}
// 3. 生成UUID
// 生成UUID和RSA密钥
profileUUID := uuid.New().String()
// 4. 生成RSA密钥对
privateKey, err := generateRSAPrivateKey()
if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err)
return nil, WrapError(err, "生成RSA密钥失败")
}
// 5. 创建档案
// 创建档案
profile := &model.Profile{
UUID: profileUUID,
UserID: userID,
Name: name,
RSAPrivateKey: privateKey,
IsActive: true, // 新创建的档案默认为活跃状态
IsActive: true,
}
if err := repository.CreateProfile(profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err)
return nil, WrapError(err, "创建档案失败")
}
// 6. 将用户的其他档案设置为非活跃
// 设置活跃状态
if err := repository.SetActiveProfile(profileUUID, userID); err != nil {
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
return nil, WrapError(err, "设置活跃状态失败")
}
return profile, nil
@@ -73,9 +68,9 @@ func GetProfileByUUID(db *gorm.DB, uuid string) (*model.Profile, error) {
profile, err := repository.FindProfileByUUID(uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("档案不存在")
return nil, ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
return nil, WrapError(err, "查询档案失败")
}
return profile, nil
}
@@ -84,32 +79,24 @@ func GetProfileByUUID(db *gorm.DB, uuid string) (*model.Profile, error) {
func GetUserProfiles(db *gorm.DB, userID int64) ([]*model.Profile, error) {
profiles, err := repository.FindProfilesByUserID(userID)
if err != nil {
return nil, fmt.Errorf("查询档案列表失败: %w", err)
return nil, WrapError(err, "查询档案列表失败")
}
return profiles, nil
}
// UpdateProfile 更新档案
func UpdateProfile(db *gorm.DB, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
// 获取档案并验证权限
profile, err := GetProfileWithPermissionCheck(uuid, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("档案不存在")
}
return nil, fmt.Errorf("查询档案失败: %w", err)
return nil, err
}
// 2. 验证权限
if profile.UserID != userID {
return nil, fmt.Errorf("无权操作此档案")
}
// 3. 检查角色名是否重复
// 检查角色名是否重复
if name != nil && *name != profile.Name {
existingName, err := repository.FindProfileByName(*name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
return nil, WrapError(err, "查询角色名失败")
}
if existingName != nil {
return nil, fmt.Errorf("角色名已被使用")
@@ -117,7 +104,7 @@ func UpdateProfile(db *gorm.DB, uuid string, userID int64, name *string, skinID,
profile.Name = *name
}
// 4. 更新皮肤和披风
// 更新皮肤和披风
if skinID != nil {
profile.SkinID = skinID
}
@@ -125,63 +112,37 @@ func UpdateProfile(db *gorm.DB, uuid string, userID int64, name *string, skinID,
profile.CapeID = capeID
}
// 5. 保存更新
if err := repository.UpdateProfile(profile); err != nil {
return nil, fmt.Errorf("更新档案失败: %w", err)
return nil, WrapError(err, "更新档案失败")
}
// 6. 重新加载关联数据
return repository.FindProfileByUUID(uuid)
}
// DeleteProfile 删除档案
func DeleteProfile(db *gorm.DB, uuid string, userID int64) error {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("档案不存在")
}
return fmt.Errorf("查询档案失败: %w", err)
if _, err := GetProfileWithPermissionCheck(uuid, userID); err != nil {
return err
}
// 2. 验证权限
if profile.UserID != userID {
return fmt.Errorf("无权操作此档案")
}
// 3. 删除档案
if err := repository.DeleteProfile(uuid); err != nil {
return fmt.Errorf("删除档案失败: %w", err)
return WrapError(err, "删除档案失败")
}
return nil
}
// SetActiveProfile 设置活跃档案
func SetActiveProfile(db *gorm.DB, uuid string, userID int64) error {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("档案不存在")
}
return fmt.Errorf("查询档案失败: %w", err)
if _, err := GetProfileWithPermissionCheck(uuid, userID); err != nil {
return err
}
// 2. 验证权限
if profile.UserID != userID {
return fmt.Errorf("无权操作此档案")
}
// 3. 设置活跃状态
if err := repository.SetActiveProfile(uuid, userID); err != nil {
return fmt.Errorf("设置活跃状态失败: %w", err)
return WrapError(err, "设置活跃状态失败")
}
// 4. 更新最后使用时间
if err := repository.UpdateProfileLastUsedAt(uuid); err != nil {
return fmt.Errorf("更新使用时间失败: %w", err)
return WrapError(err, "更新使用时间失败")
}
return nil
@@ -191,25 +152,22 @@ func SetActiveProfile(db *gorm.DB, uuid string, userID int64) error {
func CheckProfileLimit(db *gorm.DB, userID int64, maxProfiles int) error {
count, err := repository.CountProfilesByUserID(userID)
if err != nil {
return fmt.Errorf("查询档案数量失败: %w", err)
return WrapError(err, "查询档案数量失败")
}
if int(count) >= maxProfiles {
return fmt.Errorf("已达到档案数量上限(%d个", maxProfiles)
}
return nil
}
// generateRSAPrivateKey 生成RSA-2048私钥PEM格式
func generateRSAPrivateKey() (string, error) {
// 生成2048位RSA密钥对
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
// 将私钥编码为PEM格式
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
@@ -229,7 +187,7 @@ func ValidateProfileByUserID(db *gorm.DB, userId int64, UUID string) (bool, erro
if errors.Is(err, pgx.ErrNoRows) {
return false, errors.New("配置文件不存在")
}
return false, fmt.Errorf("验证配置文件失败: %w", err)
return false, WrapError(err, "验证配置文件失败")
}
return profile.UserID == userId, nil
}
@@ -237,16 +195,15 @@ func ValidateProfileByUserID(db *gorm.DB, userId int64, UUID string) (bool, erro
func GetProfilesDataByNames(db *gorm.DB, names []string) ([]*model.Profile, error) {
profiles, err := repository.GetProfilesByNames(names)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
return nil, WrapError(err, "查找失败")
}
return profiles, nil
}
// GetProfileKeyPair 从 PostgreSQL 获取密钥对GORM 实现,无手动 SQL
func GetProfileKeyPair(db *gorm.DB, profileId string) (*model.KeyPair, error) {
keyPair, err := repository.GetProfileKeyPair(profileId)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
return nil, WrapError(err, "查找失败")
}
return keyPair, nil
}

View File

@@ -0,0 +1,142 @@
package service
import (
"context"
"fmt"
"time"
"carrotskin/pkg/redis"
)
const (
// 登录失败限制配置
MaxLoginAttempts = 5 // 最大登录失败次数
LoginLockDuration = 15 * time.Minute // 账号锁定时间
LoginAttemptWindow = 10 * time.Minute // 失败次数统计窗口
// 验证码错误限制配置
MaxVerifyAttempts = 5 // 最大验证码错误次数
VerifyLockDuration = 30 * time.Minute // 验证码锁定时间
// Redis Key 前缀
LoginAttemptKeyPrefix = "security:login_attempt:"
LoginLockedKeyPrefix = "security:login_locked:"
VerifyAttemptKeyPrefix = "security:verify_attempt:"
VerifyLockedKeyPrefix = "security:verify_locked:"
)
// CheckLoginLocked 检查账号是否被锁定
func CheckLoginLocked(ctx context.Context, redisClient *redis.Client, identifier string) (bool, time.Duration, error) {
key := LoginLockedKeyPrefix + identifier
ttl, err := redisClient.TTL(ctx, key)
if err != nil {
return false, 0, err
}
if ttl > 0 {
return true, ttl, nil
}
return false, 0, nil
}
// RecordLoginFailure 记录登录失败
func RecordLoginFailure(ctx context.Context, redisClient *redis.Client, identifier string) (int, error) {
attemptKey := LoginAttemptKeyPrefix + identifier
// 增加失败次数
count, err := redisClient.Incr(ctx, attemptKey)
if err != nil {
return 0, fmt.Errorf("记录登录失败次数失败: %w", err)
}
// 设置过期时间(仅在第一次设置)
if count == 1 {
if err := redisClient.Expire(ctx, attemptKey, LoginAttemptWindow); err != nil {
return int(count), fmt.Errorf("设置过期时间失败: %w", err)
}
}
// 如果超过最大次数,锁定账号
if count >= MaxLoginAttempts {
lockedKey := LoginLockedKeyPrefix + identifier
if err := redisClient.Set(ctx, lockedKey, "1", LoginLockDuration); err != nil {
return int(count), fmt.Errorf("锁定账号失败: %w", err)
}
// 清除失败计数
_ = redisClient.Del(ctx, attemptKey)
}
return int(count), nil
}
// ClearLoginAttempts 清除登录失败记录(登录成功后调用)
func ClearLoginAttempts(ctx context.Context, redisClient *redis.Client, identifier string) error {
attemptKey := LoginAttemptKeyPrefix + identifier
return redisClient.Del(ctx, attemptKey)
}
// GetRemainingLoginAttempts 获取剩余登录尝试次数
func GetRemainingLoginAttempts(ctx context.Context, redisClient *redis.Client, identifier string) (int, error) {
attemptKey := LoginAttemptKeyPrefix + identifier
countStr, err := redisClient.Get(ctx, attemptKey)
if err != nil {
// key 不存在,返回最大次数
return MaxLoginAttempts, nil
}
var count int
fmt.Sscanf(countStr, "%d", &count)
remaining := MaxLoginAttempts - count
if remaining < 0 {
remaining = 0
}
return remaining, nil
}
// CheckVerifyLocked 检查验证码是否被锁定
func CheckVerifyLocked(ctx context.Context, redisClient *redis.Client, email, codeType string) (bool, time.Duration, error) {
key := VerifyLockedKeyPrefix + codeType + ":" + email
ttl, err := redisClient.TTL(ctx, key)
if err != nil {
return false, 0, err
}
if ttl > 0 {
return true, ttl, nil
}
return false, 0, nil
}
// RecordVerifyFailure 记录验证码验证失败
func RecordVerifyFailure(ctx context.Context, redisClient *redis.Client, email, codeType string) (int, error) {
attemptKey := VerifyAttemptKeyPrefix + codeType + ":" + email
// 增加失败次数
count, err := redisClient.Incr(ctx, attemptKey)
if err != nil {
return 0, fmt.Errorf("记录验证码失败次数失败: %w", err)
}
// 设置过期时间
if count == 1 {
if err := redisClient.Expire(ctx, attemptKey, VerifyLockDuration); err != nil {
return int(count), err
}
}
// 如果超过最大次数,锁定验证
if count >= MaxVerifyAttempts {
lockedKey := VerifyLockedKeyPrefix + codeType + ":" + email
if err := redisClient.Set(ctx, lockedKey, "1", VerifyLockDuration); err != nil {
return int(count), err
}
_ = redisClient.Del(ctx, attemptKey)
}
return int(count), nil
}
// ClearVerifyAttempts 清除验证码失败记录(验证成功后调用)
func ClearVerifyAttempts(ctx context.Context, redisClient *redis.Client, email, codeType string) error {
attemptKey := VerifyAttemptKeyPrefix + codeType + ":" + email
return redisClient.Del(ctx, attemptKey)
}

View File

@@ -12,13 +12,9 @@ import (
// CreateTexture 创建材质
func CreateTexture(db *gorm.DB, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := repository.FindUserByID(uploaderID)
if err != nil {
if _, err := EnsureUserExists(uploaderID); err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("用户不存在")
}
// 检查Hash是否已存在
existingTexture, err := repository.FindTextureByHash(hash)
@@ -30,14 +26,9 @@ func CreateTexture(db *gorm.DB, uploaderID int64, name, description, textureType
}
// 转换材质类型
var textureTypeEnum model.TextureType
switch textureType {
case "SKIN":
textureTypeEnum = model.TextureTypeSkin
case "CAPE":
textureTypeEnum = model.TextureTypeCape
default:
return nil, errors.New("无效的材质类型")
textureTypeEnum, err := parseTextureType(textureType)
if err != nil {
return nil, err
}
// 创建材质
@@ -65,58 +56,27 @@ func CreateTexture(db *gorm.DB, uploaderID int64, name, description, textureType
// GetTextureByID 根据ID获取材质
func GetTextureByID(db *gorm.DB, id int64) (*model.Texture, error) {
texture, err := repository.FindTextureByID(id)
if err != nil {
return nil, err
}
if texture == nil {
return nil, errors.New("材质不存在")
}
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
return texture, nil
return EnsureTextureExists(id)
}
// GetUserTextures 获取用户上传的材质列表
func GetUserTextures(db *gorm.DB, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize = NormalizePagination(page, pageSize)
return repository.FindTexturesByUploaderID(uploaderID, page, pageSize)
}
// SearchTextures 搜索材质
func SearchTextures(db *gorm.DB, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize = NormalizePagination(page, pageSize)
return repository.SearchTextures(keyword, textureType, publicOnly, page, pageSize)
}
// UpdateTexture 更新材质
func UpdateTexture(db *gorm.DB, textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) {
// 获取材质
texture, err := repository.FindTextureByID(textureID)
if err != nil {
// 获取材质并验证权限
if _, err := GetTextureWithPermissionCheck(textureID, uploaderID); err != nil {
return nil, err
}
if texture == nil {
return nil, errors.New("材质不存在")
}
// 检查权限:只有上传者可以修改
if texture.UploaderID != uploaderID {
return nil, errors.New("无权修改此材质")
}
// 更新字段
updates := make(map[string]interface{})
@@ -136,46 +96,27 @@ func UpdateTexture(db *gorm.DB, textureID, uploaderID int64, name, description s
}
}
// 返回更新后的材质
return repository.FindTextureByID(textureID)
}
// DeleteTexture 删除材质
func DeleteTexture(db *gorm.DB, textureID, uploaderID int64) error {
// 获取材质
texture, err := repository.FindTextureByID(textureID)
if err != nil {
if _, err := GetTextureWithPermissionCheck(textureID, uploaderID); err != nil {
return err
}
if texture == nil {
return errors.New("材质不存在")
}
// 检查权限:只有上传者可以删除
if texture.UploaderID != uploaderID {
return errors.New("无权删除此材质")
}
return repository.DeleteTexture(textureID)
}
// RecordTextureDownload 记录下载
func RecordTextureDownload(db *gorm.DB, textureID int64, userID *int64, ipAddress, userAgent string) error {
// 检查材质是否存在
texture, err := repository.FindTextureByID(textureID)
if err != nil {
if _, err := EnsureTextureExists(textureID); err != nil {
return err
}
if texture == nil {
return errors.New("材质不存在")
}
// 增加下载次数
if err := repository.IncrementTextureDownloadCount(textureID); err != nil {
return err
}
// 创建下载日志
log := &model.TextureDownloadLog{
TextureID: textureID,
UserID: userID,
@@ -188,23 +129,17 @@ func RecordTextureDownload(db *gorm.DB, textureID int64, userID *int64, ipAddres
// ToggleTextureFavorite 切换收藏状态
func ToggleTextureFavorite(db *gorm.DB, userID, textureID int64) (bool, error) {
// 检查材质是否存在
texture, err := repository.FindTextureByID(textureID)
if err != nil {
if _, err := EnsureTextureExists(textureID); err != nil {
return false, err
}
if texture == nil {
return false, errors.New("材质不存在")
}
// 检查是否已收藏
isFavorited, err := repository.IsTextureFavorited(userID, textureID)
if err != nil {
return false, err
}
if isFavorited {
// 取消收藏
// 已收藏 -> 取消收藏
if err := repository.RemoveTextureFavorite(userID, textureID); err != nil {
return false, err
}
@@ -213,7 +148,7 @@ func ToggleTextureFavorite(db *gorm.DB, userID, textureID int64) (bool, error) {
}
return false, nil
} else {
// 添加收藏
// 未收藏 -> 添加收藏
if err := repository.AddTextureFavorite(userID, textureID); err != nil {
return false, err
}
@@ -226,13 +161,7 @@ func ToggleTextureFavorite(db *gorm.DB, userID, textureID int64) (bool, error) {
// GetUserTextureFavorites 获取用户收藏的材质列表
func GetUserTextureFavorites(db *gorm.DB, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize = NormalizePagination(page, pageSize)
return repository.GetUserTextureFavorites(userID, page, pageSize)
}
@@ -249,3 +178,15 @@ func CheckTextureUploadLimit(db *gorm.DB, uploaderID int64, maxTextures int) err
return nil
}
// parseTextureType 解析材质类型
func parseTextureType(textureType string) (model.TextureType, error) {
switch textureType {
case "SKIN":
return model.TextureTypeSkin, nil
case "CAPE":
return model.TextureTypeCape, nil
default:
return "", errors.New("无效的材质类型")
}
}

View File

@@ -4,7 +4,10 @@ import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"carrotskin/pkg/redis"
"context"
"errors"
"fmt"
"strings"
"time"
)
@@ -37,7 +40,12 @@ func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar
// 确定头像URL优先使用用户提供的头像否则使用默认头像
avatarURL := avatar
if avatarURL == "" {
if avatarURL != "" {
// 验证用户提供的头像 URL 是否来自允许的域名
if err := ValidateAvatarURL(avatarURL); err != nil {
return nil, "", err
}
} else {
avatarURL = getDefaultAvatar()
}
@@ -49,8 +57,7 @@ func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar
Avatar: avatarURL,
Role: "user",
Status: 1,
Points: 0, // 初始积分可以从配置读取
// Properties 字段使用 datatypes.JSON默认为 nil数据库会存储 NULL
Points: 0,
}
if err := repository.CreateUser(user); err != nil {
@@ -63,22 +70,34 @@ func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar
return nil, "", errors.New("生成Token失败")
}
// TODO: 添加注册奖励积分
return user, token, nil
}
// LoginUser 用户登录(支持用户名或邮箱登录)
func LoginUser(jwtService *auth.JWTService, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
return LoginUserWithRateLimit(nil, jwtService, usernameOrEmail, password, ipAddress, userAgent)
}
// LoginUserWithRateLimit 用户登录(带频率限制)
func LoginUserWithRateLimit(redisClient *redis.Client, jwtService *auth.JWTService, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
ctx := context.Background()
// 检查账号是否被锁定(基于用户名/邮箱和IP
if redisClient != nil {
identifier := usernameOrEmail + ":" + ipAddress
locked, ttl, err := CheckLoginLocked(ctx, redisClient, identifier)
if err == nil && locked {
return nil, "", fmt.Errorf("登录尝试次数过多,请在 %d 分钟后重试", int(ttl.Minutes())+1)
}
}
// 查找用户:判断是用户名还是邮箱
var user *model.User
var err error
if strings.Contains(usernameOrEmail, "@") {
// 包含@符号,认为是邮箱
user, err = repository.FindUserByEmail(usernameOrEmail)
} else {
// 否则认为是用户名
user, err = repository.FindUserByUsername(usernameOrEmail)
}
@@ -86,7 +105,21 @@ func LoginUser(jwtService *auth.JWTService, usernameOrEmail, password, ipAddress
return nil, "", err
}
if user == nil {
// 记录失败日志
// 记录失败尝试
if redisClient != nil {
identifier := usernameOrEmail + ":" + ipAddress
count, _ := RecordLoginFailure(ctx, redisClient, identifier)
// 检查是否触发锁定
if count >= MaxLoginAttempts {
logFailedLogin(0, ipAddress, userAgent, "用户不存在-账号已锁定")
return nil, "", fmt.Errorf("登录失败次数过多,账号已被锁定 %d 分钟", int(LoginLockDuration.Minutes()))
}
remaining := MaxLoginAttempts - count
if remaining > 0 {
logFailedLogin(0, ipAddress, userAgent, "用户不存在")
return nil, "", fmt.Errorf("用户名/邮箱或密码错误,还剩 %d 次尝试机会", remaining)
}
}
logFailedLogin(0, ipAddress, userAgent, "用户不存在")
return nil, "", errors.New("用户名/邮箱或密码错误")
}
@@ -99,10 +132,31 @@ func LoginUser(jwtService *auth.JWTService, usernameOrEmail, password, ipAddress
// 验证密码
if !auth.CheckPassword(user.Password, password) {
// 记录失败尝试
if redisClient != nil {
identifier := usernameOrEmail + ":" + ipAddress
count, _ := RecordLoginFailure(ctx, redisClient, identifier)
// 检查是否触发锁定
if count >= MaxLoginAttempts {
logFailedLogin(user.ID, ipAddress, userAgent, "密码错误-账号已锁定")
return nil, "", fmt.Errorf("登录失败次数过多,账号已被锁定 %d 分钟", int(LoginLockDuration.Minutes()))
}
remaining := MaxLoginAttempts - count
if remaining > 0 {
logFailedLogin(user.ID, ipAddress, userAgent, "密码错误")
return nil, "", fmt.Errorf("用户名/邮箱或密码错误,还剩 %d 次尝试机会", remaining)
}
}
logFailedLogin(user.ID, ipAddress, userAgent, "密码错误")
return nil, "", errors.New("用户名/邮箱或密码错误")
}
// 登录成功,清除失败计数
if redisClient != nil {
identifier := usernameOrEmail + ":" + ipAddress
_ = ClearLoginAttempts(ctx, redisClient, identifier)
}
// 生成JWT Token
token, err := jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
@@ -141,24 +195,20 @@ func UpdateUserAvatar(userID int64, avatarURL string) error {
// ChangeUserPassword 修改密码
func ChangeUserPassword(userID int64, oldPassword, newPassword string) error {
// 获取用户
user, err := repository.FindUserByID(userID)
if err != nil {
return errors.New("用户不存在")
}
// 验证旧密码
if !auth.CheckPassword(user.Password, oldPassword) {
return errors.New("原密码错误")
}
// 加密新密码
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
}
// 更新密码
return repository.UpdateUserFields(userID, map[string]interface{}{
"password": hashedPassword,
})
@@ -166,19 +216,16 @@ func ChangeUserPassword(userID int64, oldPassword, newPassword string) error {
// ResetUserPassword 重置密码(通过邮箱)
func ResetUserPassword(email, newPassword string) error {
// 查找用户
user, err := repository.FindUserByEmail(email)
if err != nil {
return errors.New("用户不存在")
}
// 加密新密码
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
}
// 更新密码
return repository.UpdateUserFields(user.ID, map[string]interface{}{
"password": hashedPassword,
})
@@ -186,7 +233,6 @@ func ResetUserPassword(email, newPassword string) error {
// ChangeUserEmail 更换邮箱
func ChangeUserEmail(userID int64, newEmail string) error {
// 检查新邮箱是否已被使用
existingUser, err := repository.FindUserByEmail(newEmail)
if err != nil {
return err
@@ -195,7 +241,6 @@ func ChangeUserEmail(userID int64, newEmail string) error {
return errors.New("邮箱已被其他用户使用")
}
// 更新邮箱
return repository.UpdateUserFields(userID, map[string]interface{}{
"email": newEmail,
})
@@ -228,18 +273,40 @@ func logFailedLogin(userID int64, ipAddress, userAgent, reason string) {
// getDefaultAvatar 获取默认头像URL
func getDefaultAvatar() string {
// 如果数据库中不存在默认头像配置,返回错误信息
const log = "数据库中不存在默认头像配置"
// 尝试从数据库读取配置
config, err := repository.GetSystemConfigByKey("default_avatar")
if err != nil || config == nil {
return log
if err != nil || config == nil || config.Value == "" {
return ""
}
return config.Value
}
// ValidateAvatarURL 验证头像URL是否合法
func ValidateAvatarURL(avatarURL string) error {
if avatarURL == "" {
return nil
}
// 允许的域名列表
allowedDomains := []string{
"rustfs.example.com",
"localhost",
"127.0.0.1",
}
for _, domain := range allowedDomains {
if strings.Contains(avatarURL, domain) {
return nil
}
}
if strings.HasPrefix(avatarURL, "/") {
return nil
}
return errors.New("头像URL不在允许的域名列表中")
}
// GetUserByEmail 根据邮箱获取用户
func GetUserByEmail(email string) (*model.User, error) {
user, err := repository.FindUserByEmail(email)
if err != nil {
@@ -247,3 +314,31 @@ func GetUserByEmail(email string) (*model.User, error) {
}
return user, nil
}
// GetMaxProfilesPerUser 获取每用户最大档案数量配置
func GetMaxProfilesPerUser() int {
config, err := repository.GetSystemConfigByKey("max_profiles_per_user")
if err != nil || config == nil {
return 5
}
var value int
fmt.Sscanf(config.Value, "%d", &value)
if value <= 0 {
return 5
}
return value
}
// GetMaxTexturesPerUser 获取每用户最大材质数量配置
func GetMaxTexturesPerUser() int {
config, err := repository.GetSystemConfigByKey("max_textures_per_user")
if err != nil || config == nil {
return 50
}
var value int
fmt.Sscanf(config.Value, "%d", &value)
if value <= 0 {
return 50
}
return value
}

View File

@@ -91,21 +91,46 @@ func VerifyCode(ctx context.Context, redisClient *redis.Client, email, code, cod
return nil
}
// 检查是否被锁定
locked, ttl, err := CheckVerifyLocked(ctx, redisClient, email, codeType)
if err == nil && locked {
return fmt.Errorf("验证码错误次数过多,请在 %d 分钟后重试", int(ttl.Minutes())+1)
}
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
// 从Redis获取验证码
storedCode, err := redisClient.Get(ctx, codeKey)
if err != nil {
// 记录失败尝试并检查是否触发锁定
count, _ := RecordVerifyFailure(ctx, redisClient, email, codeType)
if count >= MaxVerifyAttempts {
return fmt.Errorf("验证码错误次数过多,账号已被锁定 %d 分钟", int(VerifyLockDuration.Minutes()))
}
remaining := MaxVerifyAttempts - count
if remaining > 0 {
return fmt.Errorf("验证码已过期或不存在,还剩 %d 次尝试机会", remaining)
}
return fmt.Errorf("验证码已过期或不存在")
}
// 验证验证码
if storedCode != code {
// 记录失败尝试并检查是否触发锁定
count, _ := RecordVerifyFailure(ctx, redisClient, email, codeType)
if count >= MaxVerifyAttempts {
return fmt.Errorf("验证码错误次数过多,账号已被锁定 %d 分钟", int(VerifyLockDuration.Minutes()))
}
remaining := MaxVerifyAttempts - count
if remaining > 0 {
return fmt.Errorf("验证码错误,还剩 %d 次尝试机会", remaining)
}
return fmt.Errorf("验证码错误")
}
// 验证成功,删除验证码
// 验证成功,删除验证码和失败计数
_ = redisClient.Del(ctx, codeKey)
_ = ClearVerifyAttempts(ctx, redisClient, email, codeType)
return nil
}

View File

@@ -3,6 +3,7 @@ package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context"
@@ -54,7 +55,8 @@ func VerifyPassword(db *gorm.DB, password string, Id int64) error {
if err != nil {
return errors.New("未生成密码")
}
if passwordStore != password {
// 使用 bcrypt 验证密码
if !auth.CheckPassword(passwordStore, password) {
return errors.New("密码错误")
}
return nil
@@ -81,29 +83,36 @@ func GetPasswordByUserId(db *gorm.DB, userId int64) (string, error) {
// ResetYggdrasilPassword 重置并返回新的Yggdrasil密码
func ResetYggdrasilPassword(db *gorm.DB, userId int64) (string, error) {
// 生成新的16位随机密码
newPassword := model.GenerateRandomPassword(16)
// 生成新的16位随机密码(明文,返回给用户)
plainPassword := model.GenerateRandomPassword(16)
// 使用 bcrypt 加密密码后存储
hashedPassword, err := auth.HashPassword(plainPassword)
if err != nil {
return "", fmt.Errorf("密码加密失败: %w", err)
}
// 检查Yggdrasil记录是否存在
_, err := repository.GetYggdrasilPasswordById(userId)
_, err = repository.GetYggdrasilPasswordById(userId)
if err != nil {
// 如果不存在,创建新记录
yggdrasil := model.Yggdrasil{
ID: userId,
Password: newPassword,
Password: hashedPassword,
}
if err := db.Create(&yggdrasil).Error; err != nil {
return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err)
}
return newPassword, nil
return plainPassword, nil
}
// 如果存在,更新密码
if err := repository.ResetYggdrasilPassword(userId, newPassword); err != nil {
// 如果存在,更新密码(存储加密后的密码)
if err := repository.ResetYggdrasilPassword(userId, hashedPassword); err != nil {
return "", fmt.Errorf("重置Yggdrasil密码失败: %w", err)
}
return newPassword, nil
// 返回明文密码给用户
return plainPassword, nil
}
// JoinServer 记录玩家加入服务器的会话信息

156
pkg/database/seed.go Normal file
View File

@@ -0,0 +1,156 @@
package database
import (
"carrotskin/internal/model"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// 默认管理员配置
const (
defaultAdminUsername = "admin"
defaultAdminEmail = "admin@example.com"
defaultAdminPassword = "admin123456" // 首次登录后请立即修改
)
// defaultSystemConfigs 默认系统配置
var defaultSystemConfigs = []model.SystemConfig{
{Key: "site_name", Value: "CarrotSkin", Description: "网站名称", Type: model.ConfigTypeString, IsPublic: true},
{Key: "site_description", Value: "一个优秀的Minecraft皮肤站", Description: "网站描述", Type: model.ConfigTypeString, IsPublic: true},
{Key: "registration_enabled", Value: "true", Description: "是否允许用户注册", Type: model.ConfigTypeBoolean, IsPublic: true},
{Key: "checkin_reward", Value: "10", Description: "签到奖励积分", Type: model.ConfigTypeInteger, IsPublic: true},
{Key: "texture_download_reward", Value: "1", Description: "材质被下载奖励积分", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "max_textures_per_user", Value: "50", Description: "每个用户最大材质数量", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "max_profiles_per_user", Value: "5", Description: "每个用户最大角色数量", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "default_avatar", Value: "", Description: "默认头像URL", Type: model.ConfigTypeString, IsPublic: true},
}
// defaultCasbinRules 默认Casbin权限规则
var defaultCasbinRules = []model.CasbinRule{
// 管理员拥有所有权限
{PType: "p", V0: "admin", V1: "*", V2: "*"},
// 普通用户权限
{PType: "p", V0: "user", V1: "texture", V2: "create"},
{PType: "p", V0: "user", V1: "texture", V2: "read"},
{PType: "p", V0: "user", V1: "texture", V2: "update_own"},
{PType: "p", V0: "user", V1: "texture", V2: "delete_own"},
{PType: "p", V0: "user", V1: "profile", V2: "create"},
{PType: "p", V0: "user", V1: "profile", V2: "read"},
{PType: "p", V0: "user", V1: "profile", V2: "update_own"},
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"},
{PType: "p", V0: "user", V1: "user", V2: "update_own"},
// 角色继承admin 继承 user 的所有权限
{PType: "g", V0: "admin", V1: "user"},
}
// Seed 初始化种子数据
func Seed(logger *zap.Logger) error {
db, err := GetDB()
if err != nil {
return err
}
logger.Info("开始初始化种子数据...")
// 初始化默认管理员用户
if err := seedAdminUser(db, logger); err != nil {
return err
}
// 初始化系统配置
if err := seedSystemConfigs(db, logger); err != nil {
return err
}
// 初始化Casbin权限规则
if err := seedCasbinRules(db, logger); err != nil {
return err
}
logger.Info("种子数据初始化完成")
return nil
}
// seedAdminUser 初始化默认管理员用户
func seedAdminUser(db *gorm.DB, logger *zap.Logger) error {
// 检查是否已存在管理员用户
var count int64
if err := db.Model(&model.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil {
logger.Error("检查管理员用户失败", zap.Error(err))
return err
}
// 如果已存在管理员,跳过创建
if count > 0 {
logger.Info("管理员用户已存在,跳过创建")
return nil
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
if err != nil {
logger.Error("密码加密失败", zap.Error(err))
return err
}
// 创建默认管理员
admin := &model.User{
Username: defaultAdminUsername,
Email: defaultAdminEmail,
Password: string(hashedPassword),
Role: "admin",
Status: 1,
Points: 0,
}
if err := db.Create(admin).Error; err != nil {
logger.Error("创建管理员用户失败", zap.Error(err))
return err
}
logger.Info("默认管理员用户创建成功",
zap.String("username", defaultAdminUsername),
zap.String("email", defaultAdminEmail),
)
logger.Warn("请立即登录并修改默认管理员密码!默认密码请查看源码中的 defaultAdminPassword 常量")
return nil
}
// seedSystemConfigs 初始化系统配置
func seedSystemConfigs(db *gorm.DB, logger *zap.Logger) error {
for _, config := range defaultSystemConfigs {
// 使用 FirstOrCreate 避免重复插入
var existing model.SystemConfig
result := db.Where("key = ?", config.Key).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
if err := db.Create(&config).Error; err != nil {
logger.Error("创建系统配置失败", zap.String("key", config.Key), zap.Error(err))
return err
}
logger.Info("创建系统配置", zap.String("key", config.Key))
}
}
return nil
}
// seedCasbinRules 初始化Casbin权限规则
func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
for _, rule := range defaultCasbinRules {
// 检查规则是否已存在
var existing model.CasbinRule
query := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.PType, rule.V0, rule.V1, rule.V2)
result := query.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
if err := db.Create(&rule).Error; err != nil {
logger.Error("创建Casbin规则失败", zap.String("ptype", rule.PType), zap.Error(err))
return err
}
logger.Info("创建Casbin规则", zap.String("ptype", rule.PType), zap.String("v0", rule.V0), zap.String("v1", rule.V1))
}
}
return nil
}

View File

@@ -82,6 +82,11 @@ func (c *Client) Expire(ctx context.Context, key string, expiration time.Duratio
return c.Client.Expire(ctx, key, expiration).Err()
}
// TTL 获取键的剩余过期时间
func (c *Client) TTL(ctx context.Context, key string) (time.Duration, error) {
return c.Client.TTL(ctx, key).Result()
}
// Incr 自增
func (c *Client) Incr(ctx context.Context, key string) (int64, error) {
return c.Client.Incr(ctx, key).Result()

View File

@@ -1,343 +0,0 @@
-- CarrotSkin PostgreSQL 数据库初始化脚本
-- 创建数据库(可选,如果已经创建可跳过)
--CREATE DATABASE carrotskin WITH ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8';
-- 用户表,支持积分系统和权限管理
CREATE TABLE "user" (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
password VARCHAR(255) NOT NULL DEFAULT '',
email VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
avatar VARCHAR(255) NOT NULL DEFAULT '',
points INTEGER NOT NULL DEFAULT 0,
role VARCHAR(50) NOT NULL DEFAULT 'user',
status SMALLINT NOT NULL DEFAULT 1,
last_login_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX idx_user_role ON "user"(role);
CREATE INDEX idx_user_status ON "user"(status);
CREATE INDEX idx_user_points ON "user"(points DESC);
-- 用户表注释
COMMENT ON TABLE "user" IS '用户表';
COMMENT ON COLUMN "user".id IS '用户ID';
COMMENT ON COLUMN "user".username IS '用户名';
COMMENT ON COLUMN "user".password IS '密码哈希';
COMMENT ON COLUMN "user".email IS '邮箱地址';
COMMENT ON COLUMN "user".avatar IS '头像URL存储在MinIO中';
COMMENT ON COLUMN "user".points IS '用户积分';
COMMENT ON COLUMN "user".role IS '用户角色user, admin等';
COMMENT ON COLUMN "user".status IS '用户状态1:正常, 0:禁用, -1:删除)';
COMMENT ON COLUMN "user".last_login_at IS '最后登录时间';
COMMENT ON COLUMN "user".created_at IS '创建时间';
COMMENT ON COLUMN "user".updated_at IS '更新时间';
-- 创建材质类型枚举
CREATE TYPE texture_type AS ENUM ('SKIN', 'CAPE');
-- 材质表,存储皮肤和披风
CREATE TABLE textures (
id BIGSERIAL PRIMARY KEY,
uploader_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
description TEXT,
type texture_type NOT NULL,
url VARCHAR(255) NOT NULL,
hash VARCHAR(64) NOT NULL UNIQUE,
size INTEGER NOT NULL DEFAULT 0,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
download_count INTEGER NOT NULL DEFAULT 0,
favorite_count INTEGER NOT NULL DEFAULT 0,
is_slim BOOLEAN NOT NULL DEFAULT FALSE,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_textures_uploader FOREIGN KEY (uploader_id) REFERENCES "user"(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_textures_uploader_id ON textures(uploader_id);
CREATE INDEX idx_textures_public_type_status ON textures(is_public, type, status);
CREATE INDEX idx_textures_download_count ON textures(download_count DESC);
CREATE INDEX idx_textures_favorite_count ON textures(favorite_count DESC);
-- 材质表注释
COMMENT ON TABLE textures IS '皮肤与披风材质表';
COMMENT ON COLUMN textures.id IS '材质的唯一ID';
COMMENT ON COLUMN textures.uploader_id IS '上传者的用户ID';
COMMENT ON COLUMN textures.name IS '材质名称';
COMMENT ON COLUMN textures.description IS '材质描述';
COMMENT ON COLUMN textures.type IS '材质类型(皮肤或披风)';
COMMENT ON COLUMN textures.url IS '材质在MinIO中的永久访问URL';
COMMENT ON COLUMN textures.hash IS '材质文件的SHA-256哈希值用于快速去重和校验';
COMMENT ON COLUMN textures.size IS '文件大小(字节)';
COMMENT ON COLUMN textures.is_public IS '是否公开到皮肤广场';
COMMENT ON COLUMN textures.download_count IS '下载次数';
COMMENT ON COLUMN textures.favorite_count IS '收藏次数';
COMMENT ON COLUMN textures.is_slim IS '是否为细手臂模型Alex默认为粗手臂模型Steve';
COMMENT ON COLUMN textures.status IS '状态1:正常, 0:审核中, -1:已删除)';
COMMENT ON COLUMN textures.created_at IS '创建时间';
COMMENT ON COLUMN textures.updated_at IS '更新时间';
-- 用户材质收藏表
CREATE TABLE user_texture_favorites (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
texture_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_user_texture UNIQUE (user_id, texture_id),
CONSTRAINT fk_favorites_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT fk_favorites_texture FOREIGN KEY (texture_id) REFERENCES textures(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_favorites_user_id ON user_texture_favorites(user_id);
CREATE INDEX idx_favorites_texture_id ON user_texture_favorites(texture_id);
CREATE INDEX idx_favorites_created_at ON user_texture_favorites(created_at);
-- 收藏表注释
COMMENT ON TABLE user_texture_favorites IS '用户材质收藏表';
COMMENT ON COLUMN user_texture_favorites.id IS '收藏记录的唯一ID';
COMMENT ON COLUMN user_texture_favorites.user_id IS '用户ID';
COMMENT ON COLUMN user_texture_favorites.texture_id IS '收藏的材质ID';
COMMENT ON COLUMN user_texture_favorites.created_at IS '收藏时间';
-- 用户角色信息表Minecraft档案
CREATE TABLE profiles (
uuid VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(16) NOT NULL UNIQUE,
skin_id BIGINT,
cape_id BIGINT,
rsa_private_key TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT fk_profiles_skin FOREIGN KEY (skin_id) REFERENCES textures(id) ON DELETE SET NULL,
CONSTRAINT fk_profiles_cape FOREIGN KEY (cape_id) REFERENCES textures(id) ON DELETE SET NULL
);
-- 创建索引
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
CREATE INDEX idx_profiles_active ON profiles(is_active);
-- 档案表注释
COMMENT ON TABLE profiles IS '用户角色信息表Minecraft档案';
COMMENT ON COLUMN profiles.uuid IS '角色的UUID通常为Minecraft玩家的UUID';
COMMENT ON COLUMN profiles.user_id IS '关联的用户ID';
COMMENT ON COLUMN profiles.name IS '角色名Minecraft游戏内名称';
COMMENT ON COLUMN profiles.skin_id IS '当前使用的皮肤ID';
COMMENT ON COLUMN profiles.cape_id IS '当前使用的披风ID';
COMMENT ON COLUMN profiles.rsa_private_key IS '用于签名的RSA-2048私钥PEM格式';
COMMENT ON COLUMN profiles.is_active IS '是否为活跃档案';
COMMENT ON COLUMN profiles.last_used_at IS '最后使用时间';
COMMENT ON COLUMN profiles.created_at IS '创建时间';
COMMENT ON COLUMN profiles.updated_at IS '更新时间';
-- Casbin权限管理相关表
CREATE TABLE casbin_rule (
id BIGSERIAL PRIMARY KEY,
ptype VARCHAR(100) NOT NULL,
v0 VARCHAR(100) NOT NULL DEFAULT '',
v1 VARCHAR(100) NOT NULL DEFAULT '',
v2 VARCHAR(100) NOT NULL DEFAULT '',
v3 VARCHAR(100) NOT NULL DEFAULT '',
v4 VARCHAR(100) NOT NULL DEFAULT '',
v5 VARCHAR(100) NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_casbin_rule UNIQUE (ptype, v0, v1, v2, v3, v4, v5)
);
-- 创建索引
CREATE INDEX idx_casbin_ptype ON casbin_rule(ptype);
CREATE INDEX idx_casbin_v0 ON casbin_rule(v0);
CREATE INDEX idx_casbin_v1 ON casbin_rule(v1);
-- Casbin表注释
COMMENT ON TABLE casbin_rule IS 'Casbin权限规则表';
COMMENT ON COLUMN casbin_rule.ptype IS '策略类型p, g等';
COMMENT ON COLUMN casbin_rule.v0 IS '主体(用户或角色)';
COMMENT ON COLUMN casbin_rule.v1 IS '资源对象';
COMMENT ON COLUMN casbin_rule.v2 IS '操作动作';
-- 创建变更类型枚举
CREATE TYPE point_change_type AS ENUM ('EARN', 'SPEND', 'ADMIN_ADJUST');
-- 用户积分变更记录表
CREATE TABLE user_point_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
change_type point_change_type NOT NULL,
amount INTEGER NOT NULL,
balance_before INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
reason VARCHAR(255) NOT NULL,
reference_type VARCHAR(50),
reference_id BIGINT,
operator_id BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_point_logs_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT fk_point_logs_operator FOREIGN KEY (operator_id) REFERENCES "user"(id) ON DELETE SET NULL
);
-- 创建索引
CREATE INDEX idx_point_logs_user_id ON user_point_logs(user_id);
CREATE INDEX idx_point_logs_created_at ON user_point_logs(created_at DESC);
CREATE INDEX idx_point_logs_change_type ON user_point_logs(change_type);
-- 积分日志表注释
COMMENT ON TABLE user_point_logs IS '用户积分变更记录表';
-- 创建配置类型枚举
CREATE TYPE config_type AS ENUM ('STRING', 'INTEGER', 'BOOLEAN', 'JSON');
-- 系统配置表
CREATE TABLE system_config (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(100) NOT NULL UNIQUE,
value TEXT NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
type config_type NOT NULL DEFAULT 'STRING',
is_public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX idx_system_config_public ON system_config(is_public);
-- 系统配置表注释
COMMENT ON TABLE system_config IS '系统配置表';
-- 用户登录日志表
CREATE TABLE user_login_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
ip_address INET NOT NULL,
user_agent TEXT,
login_method VARCHAR(50) NOT NULL DEFAULT 'PASSWORD',
is_success BOOLEAN NOT NULL,
failure_reason VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_login_logs_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_login_logs_user_id ON user_login_logs(user_id);
CREATE INDEX idx_login_logs_created_at ON user_login_logs(created_at DESC);
CREATE INDEX idx_login_logs_ip_address ON user_login_logs(ip_address);
CREATE INDEX idx_login_logs_success ON user_login_logs(is_success);
-- 登录日志表注释
COMMENT ON TABLE user_login_logs IS '用户登录日志表';
-- 审计日志表
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id VARCHAR(50),
old_values JSONB,
new_values JSONB,
ip_address INET NOT NULL,
user_agent TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE SET NULL
);
-- 创建索引
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
-- 审计日志表注释
COMMENT ON TABLE audit_logs IS '审计日志表';
-- 材质下载记录表(用于统计和防刷)
CREATE TABLE texture_download_logs (
id BIGSERIAL PRIMARY KEY,
texture_id BIGINT NOT NULL,
user_id BIGINT,
ip_address INET NOT NULL,
user_agent TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_download_logs_texture FOREIGN KEY (texture_id) REFERENCES textures(id) ON DELETE CASCADE,
CONSTRAINT fk_download_logs_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE SET NULL
);
-- 创建索引
CREATE INDEX idx_download_logs_texture_id ON texture_download_logs(texture_id);
CREATE INDEX idx_download_logs_user_id ON texture_download_logs(user_id);
CREATE INDEX idx_download_logs_created_at ON texture_download_logs(created_at DESC);
CREATE INDEX idx_download_logs_ip_address ON texture_download_logs(ip_address);
-- 下载记录表注释
COMMENT ON TABLE texture_download_logs IS '材质下载记录表';
-- 创建更新时间触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为需要的表添加更新时间触发器
CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON "user"
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_textures_updated_at BEFORE UPDATE ON textures
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_profiles_updated_at BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE ON system_config
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 插入默认的系统配置
INSERT INTO system_config (key, value, description, type, is_public) VALUES
('site_name', 'CarrotSkin', '网站名称', 'STRING', TRUE),
('site_description', '一个优秀的Minecraft皮肤站', '网站描述', 'STRING', TRUE),
('registration_enabled', 'true', '是否允许用户注册', 'BOOLEAN', TRUE),
('checkin_reward', '10', '签到奖励积分', 'INTEGER', TRUE),
('texture_download_reward', '1', '材质被下载奖励积分', 'INTEGER', FALSE),
('max_textures_per_user', '50', '每个用户最大材质数量', 'INTEGER', FALSE),
('max_profiles_per_user', '5', '每个用户最大角色数量', 'INTEGER', FALSE),
('default_avatar', 'https://carrotskin.com/assets/images/default-avatar.png', '默认头像', 'STRING', TRUE);
-- 插入默认的Casbin权限规则
INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
('p', 'admin', '*', '*'),
('p', 'user', 'texture', 'create'),
('p', 'user', 'texture', 'read'),
('p', 'user', 'texture', 'update_own'),
('p', 'user', 'texture', 'delete_own'),
('p', 'user', 'profile', 'create'),
('p', 'user', 'profile', 'read'),
('p', 'user', 'profile', 'update_own'),
('p', 'user', 'profile', 'delete_own'),
('p', 'user', 'user', 'update_own'),
('g', 'admin', 'user', '');
-- 插入默认的管理员
INSERT INTO "user" (username, password, email, is_admin, created_at, updated_at) VALUES
('admin', '$2a$10$...', 'admin@example.com', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);