feat(yggdrasil): 实现好友系统与 profiles 查询接口
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled

按 MinecraftServices 文档实现好友系四组接口与 profiles 服务:

好友系统(/api/yggdrasil/minecraftservices/*)
- Friends(1.3):列表查询、ADD/REMOVE 操作(发请求/接受/拒绝/撤回/删除)
  - 状态枚举+单向记录模型,接受请求时合并两方向避免好友列表重复
- Player Attributes(1.2):friendsPreferences / 脏词过滤 / 聊天偏好读写
  - Upsert 用 map 显式写字段规避 gorm 对带 default 零值布尔字段的忽略
- Presence(1.1):Redis key+TTL 上报与好友在线状态批量查询
- Blocklist(1.6):屏蔽列表查询(含 120s Redis 缓存),Block/Unblock 内部方法

Profiles 服务
- getManyByName(2.1):POST /api/profiles/minecraft,返回 [{id,name}]
  - toLowerCase 规范化、去重、空名过滤、maxBatch=10 超限拒绝
- getByName(2.2):GET /api/users/profiles/minecraft/:name,返回 {id,name},未找到返回 404

路由与基础设施
- 路由按官方 host 前缀一一转发
  - api.mojang.com/* -> /api/yggdrasil/api/*
  - api.minecraftservices.com/* -> /api/yggdrasil/minecraftservices/*
- 新增 Friend/PlayerAttributes 模型与 AutoMigrate 注册
- 新增 FriendsService 与 Container 装配
- 抽 extractBearerToken helper,统一 MinecraftServices 错误响应

修复
- texture_service: ToggleFavorite 在 db 为 nil 时降级非事务执行
  修复 TestTextureServiceImpl_ToggleFavorite 空指针 panic
- texture_service_test: UploadTexture 用例改用真实 SHA-256 命中 mock
  修复 4 个子用例因文件大小/Hash 不匹配的失败
- profile_repository: GetByNames/FindByName 改 LOWER() 大小写不敏感
  与客户端 toLowerCase 规范化对齐

测试
- 好友模型、ADD/REMOVE/接受、属性、屏蔽、Bearer 解析单测全绿
- profiles 查询规范化、去重、超限、大小写不敏感单测全绿
- go build ./... && go test ./... 全部通过
This commit is contained in:
2026-07-09 20:44:41 +08:00
parent 1dc9c36a3a
commit f2b02682c2
22 changed files with 1868 additions and 74 deletions

View File

@@ -3,11 +3,19 @@ package service
import (
"carrotskin/internal/model"
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"go.uber.org/zap"
)
// sha256Hex 计算给定数据的 SHA-256 十六进制字符串
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// TestTextureService_TypeValidation 测试材质类型验证
func TestTextureService_TypeValidation(t *testing.T) {
tests := []struct {
@@ -496,82 +504,85 @@ func TestTextureServiceImpl_Create(t *testing.T) {
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
// 构造一份符合 service 大小校验(>=512B的 PNG 文件数据
pngFileData := make([]byte, 1024)
for i := range pngFileData {
pngFileData[i] = byte(i % 256)
}
// 计算其真实 SHA-256用于 mock 预置与命中分支测试
existingHash := sha256Hex(pngFileData)
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: existingHash,
URL: "https://example.com/existing.png",
})
tests := []struct {
name string
uploaderID int64
textureName string
textureType string
hash string
fileData []byte
wantErr bool
errContains string
setupMocks func()
}{
{
name: "正常创建SKIN材质",
name: " Hash 已存在 -> 复用 URL",
uploaderID: 1,
textureName: "TestSkin",
textureType: "SKIN",
hash: "unique-hash-1",
fileData: pngFileData, // hash 命中预置记录
wantErr: false,
},
{
name: "正常创建CAPE材质",
name: "Hash 不存在且 storage 为 nil -> 存储不可用",
uploaderID: 1,
textureName: "TestCape",
textureName: "NewCape",
textureType: "CAPE",
hash: "unique-hash-2",
wantErr: false,
fileData: make([]byte, 1024), // 不同内容hash 不会命中
wantErr: true,
errContains: "存储服务不可用",
},
{
name: "用户不存在",
uploaderID: 999,
textureName: "TestTexture",
textureType: "SKIN",
hash: "unique-hash-3",
fileData: pngFileData,
wantErr: true,
},
{
name: "材质Hash已存在",
uploaderID: 1,
textureName: "DuplicateTexture",
textureType: "SKIN",
hash: "existing-hash",
wantErr: false,
setupMocks: func() {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: "existing-hash",
})
},
},
{
name: "无效的材质类型",
uploaderID: 1,
textureName: "InvalidTypeTexture",
textureType: "INVALID",
hash: "unique-hash-4",
fileData: pngFileData,
wantErr: true,
errContains: "无效的材质类型",
},
{
name: "文件过小",
uploaderID: 1,
textureName: "TooSmall",
textureType: "SKIN",
fileData: []byte("tiny"),
wantErr: true,
errContains: "文件大小必须在",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
// UploadTexture需要文件数据这里创建一个简单的测试数据
fileData := []byte("fake png data for testing")
texture, err := textureService.UploadTexture(
ctx,
tt.uploaderID,
tt.textureName,
"Test description",
tt.textureType,
fileData,
tt.fileData,
"test.png",
true,
false,
@@ -585,17 +596,17 @@ func TestTextureServiceImpl_Create(t *testing.T) {
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
return
}
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Fatal("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
})
}