2 Commits

Author SHA1 Message Date
432b875ba4 皮肤部分拿apifox测过了 2026-01-20 11:50:24 +08:00
116612ffec 修改了皮肤收藏部分 2026-01-13 18:34:21 +08:00
11 changed files with 109 additions and 121 deletions

View File

@@ -1,6 +1,3 @@
# CarrotSkin 环境配置文件示例
# 复制此文件为 .env 并修改相应的配置值
# ============================================================================= # =============================================================================
# 站点配置 # 站点配置
# ============================================================================= # =============================================================================
@@ -34,10 +31,10 @@ SERVER_SWAGGER_ENABLED=true
# 数据库配置 # 数据库配置
# ============================================================================= # =============================================================================
DATABASE_DRIVER=postgres DATABASE_DRIVER=postgres
DATABASE_HOST=localhost DATABASE_HOST=120.27.110.94
DATABASE_PORT=5432 DATABASE_PORT=5432
DATABASE_USERNAME=postgres DATABASE_USERNAME=user_wc2MbZ
DATABASE_PASSWORD=your_password_here DATABASE_PASSWORD=password_65b5aN
DATABASE_NAME=carrotskin DATABASE_NAME=carrotskin
DATABASE_SSL_MODE=disable DATABASE_SSL_MODE=disable
DATABASE_TIMEZONE=Asia/Shanghai DATABASE_TIMEZONE=Asia/Shanghai
@@ -49,19 +46,19 @@ DATABASE_CONN_MAX_IDLE_TIME=10m
# ============================================================================= # =============================================================================
# Redis配置 # Redis配置
# ============================================================================= # =============================================================================
REDIS_HOST=localhost REDIS_HOST=120.27.110.94
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=redis_ZXjbN5
REDIS_DATABASE=0 REDIS_DATABASE=0
REDIS_POOL_SIZE=10 REDIS_POOL_SIZE=10
# ============================================================================= # =============================================================================
# RustFS对象存储配置 (S3兼容) # RustFS对象存储配置 (S3兼容)
# ============================================================================= # =============================================================================
RUSTFS_ENDPOINT=127.0.0.1:9000 RUSTFS_ENDPOINT=120.27.110.94:9000
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000 RUSTFS_PUBLIC_URL=http://120.27.110.94:9000
RUSTFS_ACCESS_KEY=your_access_key RUSTFS_ACCESS_KEY=ftbulyR6rj0AZ4n5ID7g
RUSTFS_SECRET_KEY=your_secret_key RUSTFS_SECRET_KEY=P8q3VZ1wfMEdGJayu4sxh7NRSAB2H0tkFeTQlXLW
RUSTFS_USE_SSL=false RUSTFS_USE_SSL=false
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
@@ -78,6 +75,18 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=json LOG_FORMAT=json
LOG_OUTPUT=logs/app.log LOG_OUTPUT=logs/app.log
# 保留的旧配置项
LOG_MAX_SIZE=100
LOG_MAX_BACKUPS=3
LOG_MAX_AGE=28
LOG_COMPRESS=true
# =============================================================================
# 文件上传配置 (保留的旧配置项)
# =============================================================================
UPLOAD_MAX_SIZE=10485760
UPLOAD_TEXTURE_MAX_SIZE=2097152
UPLOAD_AVATAR_MAX_SIZE=1048576
# ============================================================================= # =============================================================================
# 安全配置 # 安全配置
@@ -85,15 +94,17 @@ LOG_OUTPUT=logs/app.log
# CORS 允许的来源,多个用逗号分隔 # CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=* SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔 # 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1 SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1,120.27.110.94
# 保留的旧配置项
MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCK_DURATION=30m
# ============================================================================= # =============================================================================
# 邮件配置 # 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# ============================================================================= # =============================================================================
EMAIL_ENABLED=false EMAIL_ENABLED=true
EMAIL_SMTP_HOST=smtp.example.com EMAIL_SMTP_HOST=smtp.exmail.qq.com
EMAIL_SMTP_PORT=587 EMAIL_SMTP_PORT=465
EMAIL_USERNAME=noreply@example.com EMAIL_USERNAME=system@qczlit.cn
EMAIL_PASSWORD=your-email-password EMAIL_PASSWORD=545mkewZwMzEWUjD
EMAIL_FROM_NAME=CarrotSkin EMAIL_FROM_NAME=CarrotSkin

2
.gitignore vendored
View File

@@ -60,7 +60,7 @@ configs/config.yaml
.env.production .env.production
# Keep example files # Keep example files
!.env.example !.env
# Database files # Database files
*.db *.db

View File

@@ -72,7 +72,7 @@ backend/
3. **配置环境变量** 3. **配置环境变量**
```bash ```bash
cp .env.example .env cp .env .env
# 根据实际环境填写数据库、Redis、对象存储、邮件等信息 # 根据实际环境填写数据库、Redis、对象存储、邮件等信息
``` ```

View File

@@ -17,6 +17,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
_ "time/tzdata"
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/handler" "carrotskin/internal/handler"

View File

@@ -56,17 +56,12 @@ type TextureRepository interface {
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error) // 批量删除 BatchDelete(ctx context.Context, ids []int64) (int64, error) // 批量删除
IncrementDownloadCount(ctx context.Context, id int64) error IncrementDownloadCount(ctx context.Context, id int64) error
IncrementFavoriteCount(ctx context.Context, id int64) error
DecrementFavoriteCount(ctx context.Context, id int64) error
CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error
IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error)
AddFavorite(ctx context.Context, userID, textureID int64) error
RemoveFavorite(ctx context.Context, userID, textureID int64) error
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
} }
// YggdrasilRepository Yggdrasil仓储接口 // YggdrasilRepository Yggdrasil仓储接口
type YggdrasilRepository interface { type YggdrasilRepository interface {
GetPasswordByID(ctx context.Context, id int64) (string, error) GetPasswordByID(ctx context.Context, id int64) (string, error)

View File

@@ -98,7 +98,6 @@ func TestProfileRepository_Basic(t *testing.T) {
t.Fatalf("CountByUserID mismatch: %d err=%v", count, err) t.Fatalf("CountByUserID mismatch: %d err=%v", count, err)
} }
if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil { if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil {
t.Fatalf("UpdateLastUsedAt err: %v", err) t.Fatalf("UpdateLastUsedAt err: %v", err)
} }
@@ -150,22 +149,20 @@ func TestTextureRepository_Basic(t *testing.T) {
t.Fatalf("FindByHashAndUploaderID mismatch") t.Fatalf("FindByHashAndUploaderID mismatch")
} }
_ = textureRepo.IncrementFavoriteCount(ctx, tex.ID) _, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
_ = textureRepo.DecrementFavoriteCount(ctx, tex.ID) favList, _, _ := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) == 0 {
t.Fatalf("GetUserFavorites expected at least 1 favorite")
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
favList, _, _ = textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) != 0 {
t.Fatalf("GetUserFavorites expected 0 favorites after toggle off")
}
_ = textureRepo.IncrementDownloadCount(ctx, tex.ID) _ = textureRepo.IncrementDownloadCount(ctx, tex.ID)
_ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"}) _ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"})
// 收藏
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID)
if fav, err := textureRepo.IsFavorited(ctx, u.ID, tex.ID); err == nil {
if !fav {
t.Fatalf("IsFavorited expected true")
}
} else {
t.Skipf("IsFavorited not supported by sqlite: %v", err)
}
_ = textureRepo.RemoveFavorite(ctx, u.ID, tex.ID)
// 批量更新与删除 // 批量更新与删除
if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 { if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 {
t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err) t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err)
@@ -187,7 +184,7 @@ func TestTextureRepository_Basic(t *testing.T) {
if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 { if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 {
t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err) t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err)
} }
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID+1) _, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID+1)
if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 { if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 {
t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err) t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err)
} }
@@ -206,7 +203,6 @@ func TestTextureRepository_Basic(t *testing.T) {
_ = textureRepo.Delete(ctx, tex.ID) _ = textureRepo.Delete(ctx, tex.ID)
} }
func TestClientRepository_Basic(t *testing.T) { func TestClientRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t) db := testutil.NewTestDB(t)
repo := NewClientRepository(db) repo := NewClientRepository(db)

View File

@@ -138,42 +138,52 @@ func (r *textureRepository) IncrementDownloadCount(ctx context.Context, id int64
UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error
} }
func (r *textureRepository) IncrementFavoriteCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1)).Error
}
func (r *textureRepository) DecrementFavoriteCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1)).Error
}
func (r *textureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error { func (r *textureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return r.db.WithContext(ctx).Create(log).Error return r.db.WithContext(ctx).Create(log).Error
} }
func (r *textureRepository) IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) { func (r *textureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
var count int64 var isAdded bool
// 使用 Select("1") 优化,只查询是否存在,不需要查询所有字段 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := r.db.WithContext(ctx).Model(&model.UserTextureFavorite{}). var count int64
Select("1"). err := tx.Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID). Where("user_id = ? AND texture_id = ?", userID, textureID).
Limit(1). Count(&count).Error
Count(&count).Error if err != nil {
return count > 0, err return err
} }
func (r *textureRepository) AddFavorite(ctx context.Context, userID, textureID int64) error { if count > 0 {
favorite := &model.UserTextureFavorite{ result := tx.Where("user_id = ? AND texture_id = ?", userID, textureID).
UserID: userID, Delete(&model.UserTextureFavorite{})
TextureID: textureID, if result.Error != nil {
} return result.Error
return r.db.WithContext(ctx).Create(favorite).Error }
} if result.RowsAffected > 0 {
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("GREATEST(favorite_count - 1, 0)")).Error; err != nil {
return err
}
}
isAdded = false
return nil
}
func (r *textureRepository) RemoveFavorite(ctx context.Context, userID, textureID int64) error { favorite := &model.UserTextureFavorite{
return r.db.WithContext(ctx).Where("user_id = ? AND texture_id = ?", userID, textureID). UserID: userID,
Delete(&model.UserTextureFavorite{}).Error TextureID: textureID,
}
if err := tx.Create(favorite).Error; err != nil {
return err
}
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + 1")).Error; err != nil {
return err
}
isAdded = true
return nil
})
return isAdded, err
} }
func (r *textureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (r *textureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {

View File

@@ -391,37 +391,24 @@ func (m *MockTextureRepository) IncrementFavoriteCount(ctx context.Context, id i
return nil return nil
} }
func (m *MockTextureRepository) DecrementFavoriteCount(ctx context.Context, id int64) error {
if texture, ok := m.textures[id]; ok && texture.FavoriteCount > 0 {
texture.FavoriteCount--
}
return nil
}
func (m *MockTextureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error { func (m *MockTextureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return nil return nil
} }
func (m *MockTextureRepository) IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) { func (m *MockTextureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
if userFavs, ok := m.favorites[userID]; ok {
return userFavs[textureID], nil
}
return false, nil
}
func (m *MockTextureRepository) AddFavorite(ctx context.Context, userID, textureID int64) error {
if m.favorites[userID] == nil { if m.favorites[userID] == nil {
m.favorites[userID] = make(map[int64]bool) m.favorites[userID] = make(map[int64]bool)
} }
m.favorites[userID][textureID] = true isFavorited := m.favorites[userID][textureID]
return nil m.favorites[userID][textureID] = !isFavorited
} if texture, ok := m.textures[textureID]; ok {
if !isFavorited {
func (m *MockTextureRepository) RemoveFavorite(ctx context.Context, userID, textureID int64) error { texture.FavoriteCount++
if userFavs, ok := m.favorites[userID]; ok { } else if texture.FavoriteCount > 0 {
delete(userFavs, textureID) texture.FavoriteCount--
}
} }
return nil return !isFavorited, nil
} }
func (m *MockTextureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (m *MockTextureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -474,7 +461,6 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil return deleted, nil
} }
// ============================================================================ // ============================================================================
// Service Mocks // Service Mocks
// ============================================================================ // ============================================================================

View File

@@ -219,39 +219,22 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
} }
func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) { func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
// 确保材质存在
texture, err := s.textureRepo.FindByID(ctx, textureID) texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil { if err != nil {
return false, err return false, err
} }
if texture == nil { if texture == nil || texture.Status != 1 || !texture.IsPublic {
return false, ErrTextureNotFound return false, ErrTextureNotFound
} }
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID) isAdded, err := s.textureRepo.ToggleFavorite(ctx, userID, textureID)
if err != nil { if err != nil {
return false, err return false, err
} }
if isFavorited { s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.UserFavoritesPattern(userID))
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return false, nil
}
// 未收藏 -> 添加收藏 return isAdded, nil
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return true, nil
} }
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"carrotskin/internal/model" "carrotskin/internal/model"
"context" "context"
"strings"
"testing" "testing"
"go.uber.org/zap" "go.uber.org/zap"
@@ -564,7 +565,7 @@ func TestTextureServiceImpl_Create(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// UploadTexture需要文件数据这里创建一个简单的测试数据 // UploadTexture需要文件数据这里创建一个简单的测试数据
fileData := []byte("fake png data for testing") fileData := []byte(strings.Repeat("x", 512))
texture, err := textureService.UploadTexture( texture, err := textureService.UploadTexture(
ctx, ctx,
tt.uploaderID, tt.uploaderID,
@@ -760,7 +761,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
UploaderID: 1, UploaderID: 1,
Name: "T", Name: "T",
}) })
_ = textureRepo.AddFavorite(context.Background(), 1, i) _, _ = textureRepo.ToggleFavorite(context.Background(), 1, i)
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()

View File

@@ -369,6 +369,11 @@ func (b *CacheKeyBuilder) ProfilePattern(userID int64) string {
return fmt.Sprintf("%sprofile:*:%d*", b.prefix, userID) return fmt.Sprintf("%sprofile:*:%d*", b.prefix, userID)
} }
// UserFavoritesPattern 用户收藏相关的所有缓存键模式
func (b *CacheKeyBuilder) UserFavoritesPattern(userID int64) string {
return fmt.Sprintf("%sfavorites:*:%d*", b.prefix, userID)
}
// Exists 检查缓存键是否存在 // Exists 检查缓存键是否存在
func (cm *CacheManager) Exists(ctx context.Context, key string) (bool, error) { func (cm *CacheManager) Exists(ctx context.Context, key string) (bool, error) {
if !cm.config.Enabled || cm.redis == nil { if !cm.config.Enabled || cm.redis == nil {