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

@@ -0,0 +1,260 @@
package service
import (
"context"
"testing"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/utils"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newFriendsTestDB 构建内存 sqlite 并迁移所需表
func newFriendsTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Profile{}, &model.Friend{}, &model.PlayerAttributes{}); err != nil {
t.Fatalf("migrate: %v", err)
}
// 清理共享内存库的残留数据
db.Exec("DELETE FROM friends")
db.Exec("DELETE FROM profiles")
db.Exec("DELETE FROM player_attributes")
return db
}
// newFriendsService 用真实 GORM repo + nil redis 构造服务presence/blocklist 缓存路径会跳过)
func newFriendsService(t *testing.T) (FriendsService, *gorm.DB) {
t.Helper()
db := newFriendsTestDB(t)
friendRepo := repository.NewFriendRepository(db)
attrRepo := repository.NewPlayerAttributeRepository(db)
profileRepo := repository.NewProfileRepository(db)
return NewFriendsService(friendRepo, attrRepo, profileRepo, nil, zap.NewNop()), db
}
func mustCreateProfile(t *testing.T, db *gorm.DB, name string) *model.Profile {
t.Helper()
p := &model.Profile{
UUID: utils.GenerateUUID(),
UserID: 1,
Name: name,
RSAPrivateKey: "x",
}
if err := db.Create(p).Error; err != nil {
t.Fatalf("create profile %s: %v", name, err)
}
return p
}
// TestAddFriend_SelfReject 添加自己应报错
func TestAddFriend_SelfReject(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "self")
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
ProfileID: p.UUID,
UpdateType: FriendActionAdd,
})
if err == nil {
t.Fatal("添加自己应报错")
}
}
// TestAddFriend_UnknownProfile 目标不存在应报错
func TestAddFriend_UnknownProfile(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
// 使用 32 位无连字符 UUID规避 FormatUUIDToNoDash 对非法长度的告警路径
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
ProfileID: "ffffffffffffffffffffffffffffffff",
UpdateType: FriendActionAdd,
})
if err == nil {
t.Fatal("不存在的目标应报错")
}
}
// TestAddFriend_SendRequestThenAccept 双向流程A 发请求 -> B 接受 -> 互为好友
func TestAddFriend_SendRequestThenAccept(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// A 向 B 发起请求
resp, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{
ProfileID: b.UUID,
UpdateType: FriendActionAdd,
})
if err != nil {
t.Fatalf("A 发起请求失败: %v", err)
}
if len(resp.OutgoingRequests) != 1 || resp.OutgoingRequests[0].ProfileID != b.UUID {
t.Fatalf("A 的 outgoing 应包含 B, got %+v", resp.OutgoingRequests)
}
// B 视角incoming 应包含 A
respB, err := svc.ListFriends(context.Background(), b.UUID)
if err != nil {
t.Fatalf("B 查询列表失败: %v", err)
}
if len(respB.IncomingRequests) != 1 || respB.IncomingRequests[0].ProfileID != a.UUID {
t.Fatalf("B 的 incoming 应包含 A, got %+v", respB.IncomingRequests)
}
// B 接受请求B 用 ADD 指向 A -> 自动接受)
resp, err = svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{
ProfileID: a.UUID,
UpdateType: FriendActionAdd,
})
if err != nil {
t.Fatalf("B 接受请求失败: %v", err)
}
if len(resp.Friends) != 1 || resp.Friends[0].ProfileID != a.UUID {
t.Fatalf("B 的 friends 应包含 A, got %+v", resp.Friends)
}
// A 视角:也是好友
respA, err := svc.ListFriends(context.Background(), a.UUID)
if err != nil {
t.Fatalf("A 查询列表失败: %v", err)
}
if len(respA.Friends) != 1 || respA.Friends[0].ProfileID != b.UUID {
t.Fatalf("A 的 friends 应包含 B, got %+v", respA.Friends)
}
}
// TestRemoveFriend_Flow A B 互为好友后 A 删除好友 -> 双方都无好友
func TestRemoveFriend_Flow(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// 互为好友
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
// A 删除好友
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionRemove}); err != nil {
t.Fatalf("A 删除好友失败: %v", err)
}
// B 视角应不再有好友
respB, err := svc.ListFriends(context.Background(), b.UUID)
if err != nil {
t.Fatal(err)
}
if len(respB.Friends) != 0 {
t.Fatalf("B 应无好友, got %+v", respB.Friends)
}
}
// TestDeclineIncomingRequest B 拒绝 A 的请求 -> A 的 outgoing 清空
func TestDeclineIncomingRequest(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
// B 拒绝B 用 REMOVE 指向 A
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionRemove}); err != nil {
t.Fatalf("B 拒绝失败: %v", err)
}
// A 的 outgoing 应清空
respA, err := svc.ListFriends(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(respA.OutgoingRequests) != 0 {
t.Fatalf("A 的 outgoing 应已清空, got %+v", respA.OutgoingRequests)
}
}
// TestGetAttributes_Default 默认属性
func TestGetAttributes_Default(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
resp, err := svc.GetAttributes(context.Background(), p.UUID)
if err != nil {
t.Fatal(err)
}
if !resp.ProfanityFilterPreferences.ProfanityFilterOn {
t.Error("默认应启用脏词过滤")
}
if resp.FriendsPreferences.Friends != "ENABLED" {
t.Errorf("默认 friends = %q, want ENABLED", resp.FriendsPreferences.Friends)
}
}
// TestUpdateAttributes_PartialUpdate 部分更新保留其它字段
func TestUpdateAttributes_PartialUpdate(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
off := false
if err := svc.UpdateAttributes(context.Background(), p.UUID, PlayerAttributesRequest{
ProfanityFilterPreferences: &ProfanityFilterPreferences{ProfanityFilterOn: &off},
}); err != nil {
t.Fatalf("UpdateAttributes err: %v", err)
}
var row model.PlayerAttributes
if err := db.First(&row, "profile_uuid = ?", p.UUID).Error; err != nil {
t.Fatalf("查询 row 失败: %v", err)
}
t.Logf("DB row: %+v", row)
resp, err := svc.GetAttributes(context.Background(), p.UUID)
if err != nil {
t.Fatalf("GetAttributes err: %v", err)
}
if resp.ProfanityFilterPreferences.ProfanityFilterOn {
t.Error("脏词过滤应已关闭")
}
if resp.FriendsPreferences.Friends != "ENABLED" {
t.Errorf("friends 应保留 ENABLED, got %q", resp.FriendsPreferences.Friends)
}
}
// TestBlocklist_BlockUnblock 屏蔽/取消屏蔽
func TestBlocklist_BlockUnblock(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// 使用类型断言访问 Block/Unblock接口未暴露但实现有
fs := svc.(*friendsService)
if err := fs.Block(context.Background(), a.UUID, b.UUID); err != nil {
t.Fatalf("屏蔽失败: %v", err)
}
resp, err := svc.GetBlocklist(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(resp.BlockedProfiles) != 1 || resp.BlockedProfiles[0] != b.UUID {
t.Fatalf("屏蔽列表应含 B, got %+v", resp.BlockedProfiles)
}
if err := fs.Unblock(context.Background(), a.UUID, b.UUID); err != nil {
t.Fatalf("取消屏蔽失败: %v", err)
}
resp, err = svc.GetBlocklist(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(resp.BlockedProfiles) != 0 {
t.Fatalf("屏蔽列表应空, got %+v", resp.BlockedProfiles)
}
}