按 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 ./... 全部通过
261 lines
8.4 KiB
Go
261 lines
8.4 KiB
Go
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)
|
||
}
|
||
}
|