feat(yggdrasil): 实现好友系统与 profiles 查询接口
按 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:
@@ -60,6 +60,14 @@ type ProfileService interface {
|
||||
// 批量查询
|
||||
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
||||
GetByProfileName(ctx context.Context, name string) (*model.Profile, error)
|
||||
|
||||
// ProfileSearchByName 按用户名批量查询档案,返回简化的 NameAndId 列表(文档 2.1)
|
||||
// 规范化(toLowerCase)、空名过滤、每页最多 maxBatch 个限制由本方法在服务端兜底处理。
|
||||
ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error)
|
||||
|
||||
// ProfileSearchByNameSingle 按单个用户名查询档案,返回简化的 NameAndId(文档 2.2)
|
||||
// 未找到时返回 (nil, nil),符合文档中"客户端返回 Optional.empty()"的契约。
|
||||
ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error)
|
||||
}
|
||||
|
||||
// TextureService 材质服务接口
|
||||
@@ -138,6 +146,115 @@ type YggdrasilService interface {
|
||||
GetPublicKey(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// FriendActionType 好友操作类型(文档 1.3.2)
|
||||
type FriendActionType string
|
||||
|
||||
const (
|
||||
FriendActionAdd FriendActionType = "ADD"
|
||||
FriendActionRemove FriendActionType = "REMOVE"
|
||||
)
|
||||
|
||||
// FriendActionRequest 好友操作请求(文档 1.3.2)
|
||||
type FriendActionRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
ProfileID string `json:"profileId,omitempty"`
|
||||
UpdateType FriendActionType `json:"updateType"`
|
||||
}
|
||||
|
||||
// FriendDTO 好友条目(文档 1.3.1 FriendDto)
|
||||
type FriendDTO struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// FriendsListResponse 好友列表响应(文档 1.3.1)
|
||||
type FriendsListResponse struct {
|
||||
Friends []FriendDTO `json:"friends"`
|
||||
IncomingRequests []FriendDTO `json:"incomingRequests"`
|
||||
OutgoingRequests []FriendDTO `json:"outgoingRequests"`
|
||||
}
|
||||
|
||||
// PlayerAttributesRequest 玩家偏好更新请求(文档 1.2.2)
|
||||
type PlayerAttributesRequest struct {
|
||||
ProfanityFilterPreferences *ProfanityFilterPreferences `json:"profanityFilterPreferences,omitempty"`
|
||||
FriendsPreferences *FriendsPreferences `json:"friendsPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// ProfanityFilterPreferences 脏词过滤偏好
|
||||
type ProfanityFilterPreferences struct {
|
||||
ProfanityFilterOn *bool `json:"profanityFilterOn,omitempty"`
|
||||
}
|
||||
|
||||
// FriendsPreferences 好友偏好
|
||||
type FriendsPreferences struct {
|
||||
Friends *string `json:"friends,omitempty"` // ENABLED / DISABLED
|
||||
AcceptInvites *string `json:"acceptInvites,omitempty"` // ENABLED / DISABLED
|
||||
}
|
||||
|
||||
// PlayerAttributesResponse 玩家属性响应(文档 1.2.1)
|
||||
// 仅实现好友相关字段,其它(privileges/banStatus 等)不在好友系统范围
|
||||
type PlayerAttributesResponse struct {
|
||||
ProfanityFilterPreferences ProfanityFilterPreferencesResp `json:"profanityFilterPreferences"`
|
||||
FriendsPreferences FriendsPreferencesResp `json:"friendsPreferences"`
|
||||
ChatPreferences ChatPreferencesResp `json:"chatPreferences"`
|
||||
}
|
||||
|
||||
// ProfanityFilterPreferencesResp 脏词过滤偏好响应
|
||||
type ProfanityFilterPreferencesResp struct {
|
||||
ProfanityFilterOn bool `json:"profanityFilterOn"`
|
||||
}
|
||||
|
||||
// FriendsPreferencesResp 好友偏好响应
|
||||
type FriendsPreferencesResp struct {
|
||||
Friends string `json:"friends"`
|
||||
AcceptInvites string `json:"acceptInvites"`
|
||||
}
|
||||
|
||||
// ChatPreferencesResp 聊天偏好响应
|
||||
type ChatPreferencesResp struct {
|
||||
TextCommunication string `json:"textCommunication"`
|
||||
}
|
||||
|
||||
// PresenceRequest 在线状态上报请求(文档 1.1)
|
||||
type PresenceRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PresenceEntry 单个在线玩家状态(文档 1.1)
|
||||
type PresenceEntry struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
PMID string `json:"pmid,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// PresenceResponse 在线状态响应(文档 1.1)
|
||||
type PresenceResponse struct {
|
||||
Presence []PresenceEntry `json:"presence"`
|
||||
}
|
||||
|
||||
// BlockListResponse 屏蔽列表响应(文档 1.6)
|
||||
type BlockListResponse struct {
|
||||
BlockedProfiles []string `json:"blockedProfiles"`
|
||||
}
|
||||
|
||||
// FriendsService 好友系统服务接口(文档 1.1 / 1.2 / 1.3 / 1.6)
|
||||
type FriendsService interface {
|
||||
// 好友列表与操作
|
||||
ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error)
|
||||
PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error)
|
||||
|
||||
// 玩家偏好属性
|
||||
GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error)
|
||||
UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error
|
||||
|
||||
// 在线状态
|
||||
UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error)
|
||||
|
||||
// 屏蔽列表
|
||||
GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error)
|
||||
}
|
||||
|
||||
// SecurityService 安全服务接口
|
||||
type SecurityService interface {
|
||||
// 登录安全
|
||||
@@ -162,6 +279,7 @@ type Services struct {
|
||||
Captcha CaptchaService
|
||||
Yggdrasil YggdrasilService
|
||||
Security SecurityService
|
||||
Friends FriendsService
|
||||
}
|
||||
|
||||
// ServiceDeps 服务依赖
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"carrotskin/pkg/database"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -190,8 +191,10 @@ func (m *MockProfileRepository) FindByName(ctx context.Context, name string) (*m
|
||||
if m.FailFind {
|
||||
return nil, errors.New("mock find error")
|
||||
}
|
||||
// 与真实仓储一致:大小写不敏感匹配
|
||||
ln := strings.ToLower(name)
|
||||
for _, profile := range m.profiles {
|
||||
if profile.Name == name {
|
||||
if strings.ToLower(profile.Name) == ln {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
@@ -243,8 +246,10 @@ func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid strin
|
||||
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
||||
var result []*model.Profile
|
||||
for _, name := range names {
|
||||
// 与真实仓储一致:大小写不敏感匹配
|
||||
ln := strings.ToLower(name)
|
||||
for _, profile := range m.profiles {
|
||||
if profile.Name == name {
|
||||
if strings.ToLower(profile.Name) == ln {
|
||||
result = append(result, profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
@@ -240,6 +241,80 @@ func (s *profileService) GetByProfileName(ctx context.Context, name string) (*mo
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// ProfileSearchByName 按用户名批量查询档案,返回 NameAndId 列表(文档 2.1)
|
||||
// 实现要点:
|
||||
// - 客户端会 toLowerCase 规范化用户名,服务端也做一次兜底(结合仓储大小写不敏感查询);
|
||||
// - 过滤掉空名与重复名;
|
||||
// - 当 maxBatch > 0 且入参个数超过 maxBatch 时按文档契约返回错误(避免超大查询)。
|
||||
func (s *profileService) ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error) {
|
||||
// 规范化 + 去重 + 过滤空名
|
||||
normalized := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, n := range names {
|
||||
// toLowerCase 兜底,与客户端行为一致
|
||||
ln := strings.ToLower(strings.TrimSpace(n))
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ln]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ln] = struct{}{}
|
||||
normalized = append(normalized, ln)
|
||||
}
|
||||
|
||||
if maxBatch > 0 && len(normalized) > maxBatch {
|
||||
return nil, fmt.Errorf("单次最多查询 %d 个用户名", maxBatch)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []model.NameAndId{}, nil
|
||||
}
|
||||
|
||||
profiles, err := s.profileRepo.GetByNames(ctx, normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找失败: %w", err)
|
||||
}
|
||||
|
||||
// 映射为 NameAndId(仅 id + name,保持存储的大小写)
|
||||
result := make([]model.NameAndId, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, model.NameAndId{
|
||||
ID: p.UUID,
|
||||
Name: p.Name,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProfileSearchByNameSingle 按单个用户名查询档案,返回 NameAndId(文档 2.2)
|
||||
// 实现要点:
|
||||
// - name 经 toLowerCase + trim 兜底规范化,配合仓储大小写不敏感查询;
|
||||
// - 空名直接返回 (nil, nil),符合文档"客户端返回 Optional.empty()"的契约;
|
||||
// - 查询错误或未命中均返回 (nil, nil),仅记日志,不上抛错误(与文档行为一致)。
|
||||
func (s *profileService) ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error) {
|
||||
ln := strings.ToLower(strings.TrimSpace(name))
|
||||
if ln == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profile, err := s.profileRepo.FindByName(ctx, ln)
|
||||
if err != nil {
|
||||
// 记录非致命错误但不返回,与文档"任意错误返回 Optional.empty()"保持一致
|
||||
s.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &model.NameAndId{
|
||||
ID: profile.UUID,
|
||||
Name: profile.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateRSAPrivateKeyInternal 生成RSA-2048私钥(PEM格式)
|
||||
func generateRSAPrivateKeyInternal() (string, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
|
||||
@@ -686,4 +686,46 @@ func TestProfileServiceImpl_CheckLimit_And_GetByNames(t *testing.T) {
|
||||
if err != nil || p == nil || p.Name != "A" {
|
||||
t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err)
|
||||
}
|
||||
|
||||
// ProfileSearchByName:小写名查询、去重、过滤空名、超限拒单
|
||||
got, err := svc.ProfileSearchByName(ctx, []string{"a", "A", "b", "", " "}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ProfileSearchByName 失败: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("ProfileSearchByName 应去重返回 2 项, got=%d: %+v", len(got), got)
|
||||
}
|
||||
byName := make(map[string]string, len(got))
|
||||
for _, n := range got {
|
||||
byName[n.Name] = n.ID
|
||||
}
|
||||
if byName["A"] != "a" || byName["B"] != "b" {
|
||||
t.Fatalf("返回的 id/name 映射错误: %+v", byName)
|
||||
}
|
||||
|
||||
// 超过 maxBatch 应报错
|
||||
if _, err := svc.ProfileSearchByName(ctx, []string{"a", "b", "c"}, 2); err == nil {
|
||||
t.Fatalf("ProfileSearchByName 超过 maxBatch 应返回错误")
|
||||
}
|
||||
|
||||
// ProfileSearchByNameSingle:存在 / 不存在 / 空名
|
||||
single, err := svc.ProfileSearchByNameSingle(ctx, "a")
|
||||
if err != nil || single == nil || single.ID != "a" || single.Name != "A" {
|
||||
t.Fatalf("ProfileSearchByNameSingle 存在用例失败, got=%+v err=%v", single, err)
|
||||
}
|
||||
// 小写名应能命中大小写不敏感查询
|
||||
singleLower, err := svc.ProfileSearchByNameSingle(ctx, "a")
|
||||
if err != nil || singleLower == nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 小写名匹配失败, got=%+v err=%v", singleLower, err)
|
||||
}
|
||||
// 不存在
|
||||
missing, err := svc.ProfileSearchByNameSingle(ctx, "nope")
|
||||
if err != nil || missing != nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 不存在应返回 (nil,nil), got=%+v err=%v", missing, err)
|
||||
}
|
||||
// 空名
|
||||
emptyName, err := svc.ProfileSearchByNameSingle(ctx, " ")
|
||||
if err != nil || emptyName != nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 空名应返回 (nil,nil), got=%+v err=%v", emptyName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +236,10 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
|
||||
}
|
||||
|
||||
// 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
|
||||
// 注意:s.db 在部分单元测试中可能为 nil,此时跳过事务直接执行
|
||||
// (生产环境 db 永远非空,mock repo 本就不是真事务,降级不影响一致性)
|
||||
var favorited bool
|
||||
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
|
||||
toggle := func() error {
|
||||
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -262,7 +264,15 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
|
||||
}
|
||||
favorited = true
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
|
||||
return toggle()
|
||||
})
|
||||
} else {
|
||||
err = toggle()
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
511
internal/service/yggdrasil_friends_service.go
Normal file
511
internal/service/yggdrasil_friends_service.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PresenceKeyPrefix 在线状态 Redis 键前缀,key 形如 presence:<profileUUID>
|
||||
PresenceKeyPrefix = "presence:"
|
||||
// PresenceTTL 在线状态过期时间;客户端最长 5 分钟必发一次心跳(文档 1.1)
|
||||
PresenceTTL = 5 * time.Minute
|
||||
// BlocklistCacheTTL 屏蔽列表缓存冷却时间(文档 1.6:客户端 120 秒节流)
|
||||
BlocklistCacheTTL = 120 * time.Second
|
||||
// BlocklistCacheKeyPrefix 屏蔽列表缓存键前缀
|
||||
BlocklistCacheKeyPrefix = "blocklist:"
|
||||
|
||||
// PresenceStatusOffline 离线状态固定值(文档 1.1)
|
||||
PresenceStatusOffline = "OFFLINE"
|
||||
)
|
||||
|
||||
// friendsService 好友系统服务实现
|
||||
type friendsService struct {
|
||||
friendRepo repository.FriendRepository
|
||||
attrRepo repository.PlayerAttributeRepository
|
||||
profileRepo repository.ProfileRepository
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
presenceTTL time.Duration
|
||||
blocklistTTL time.Duration
|
||||
}
|
||||
|
||||
// NewFriendsService 创建好友系统服务实例
|
||||
func NewFriendsService(
|
||||
friendRepo repository.FriendRepository,
|
||||
attrRepo repository.PlayerAttributeRepository,
|
||||
profileRepo repository.ProfileRepository,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
) FriendsService {
|
||||
return &friendsService{
|
||||
friendRepo: friendRepo,
|
||||
attrRepo: attrRepo,
|
||||
profileRepo: profileRepo,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
presenceTTL: PresenceTTL,
|
||||
blocklistTTL: BlocklistCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 好友列表与操作
|
||||
// ============================================================================
|
||||
|
||||
// ListFriends 获取好友列表(文档 1.3.1)
|
||||
func (s *friendsService) ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error) {
|
||||
accepted, err := s.friendRepo.ListAccepted(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询好友列表失败: %w", err)
|
||||
}
|
||||
incoming, err := s.friendRepo.ListIncoming(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询收到的请求失败: %w", err)
|
||||
}
|
||||
outgoing, err := s.friendRepo.ListOutgoing(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询发出的请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 批量解析 profile name
|
||||
friends, err := s.toFriendDTOs(ctx, accepted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incomingDTOs, err := s.toFriendDTOs(ctx, incoming)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outgoingDTOs, err := s.toFriendDTOs(ctx, outgoing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FriendsListResponse{
|
||||
Friends: friends,
|
||||
IncomingRequests: incomingDTOs,
|
||||
OutgoingRequests: outgoingDTOs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PerformFriendAction 执行好友加/删操作(文档 1.3.2)
|
||||
func (s *friendsService) PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error) {
|
||||
switch req.UpdateType {
|
||||
case FriendActionAdd:
|
||||
if err := s.addFriend(ctx, requesterUUID, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case FriendActionRemove:
|
||||
if err := s.removeFriend(ctx, requesterUUID, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, apperrors.NewBadRequest("无效的 updateType", nil)
|
||||
}
|
||||
// 文档 1.3.2:PUT 成功后返回最新好友列表
|
||||
return s.ListFriends(ctx, requesterUUID)
|
||||
}
|
||||
|
||||
// addFriend 添加好友 / 发送请求 / 接受请求(文档 1.3.2 ADD)
|
||||
func (s *friendsService) addFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error {
|
||||
targetUUID, err := s.resolveTargetUUID(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验目标存在
|
||||
targetProfile, err := s.profileRepo.FindByUUID(ctx, targetUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return fmt.Errorf("查询目标档案失败: %w", err)
|
||||
}
|
||||
if targetProfile == nil {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
|
||||
// 不能添加自己
|
||||
if requesterUUID == targetUUID {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil)
|
||||
}
|
||||
|
||||
// 检查 requester->target 已有记录
|
||||
existing, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询已有关系失败: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
switch existing.Status {
|
||||
case model.FriendsStatusAccepted:
|
||||
// 已是好友,幂等返回成功
|
||||
return nil
|
||||
case model.FriendsStatusBlocked:
|
||||
// requester 屏蔽了 target,解除屏蔽并建好友
|
||||
if err := s.friendRepo.Delete(ctx, existing.ID); err != nil {
|
||||
return fmt.Errorf("解除屏蔽失败: %w", err)
|
||||
}
|
||||
case model.FriendsStatusPending:
|
||||
// 已发过请求,幂等返回
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 target->requester 是否已向我发出 pending 请求 -> 自动接受
|
||||
reverse, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询反向关系失败: %w", err)
|
||||
}
|
||||
if reverse != nil {
|
||||
switch reverse.Status {
|
||||
case model.FriendsStatusPending:
|
||||
// 对方已向我发起请求,直接互为好友(更新 A→B 为 accepted 即可,
|
||||
// ListAccepted 会合并两方向,无需补建 B→A 以避免好友列表重复)
|
||||
if err := s.friendRepo.UpdateStatus(ctx, reverse.ID, model.FriendsStatusAccepted); err != nil {
|
||||
return fmt.Errorf("接受好友请求失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
case model.FriendsStatusAccepted:
|
||||
// 对方已记录为好友(数据修复场景),确保 requester->target 也存在 accepted 记录
|
||||
if existing == nil {
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: requesterUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusAccepted,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("创建好友记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case model.FriendsStatusBlocked:
|
||||
// 对方屏蔽了我,不接受好友请求
|
||||
return apperrors.NewForbidden("对方已屏蔽你")
|
||||
}
|
||||
}
|
||||
|
||||
// 普通发送请求
|
||||
if existing == nil {
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: requesterUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusPending,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送好友请求失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeFriend 删除好友 / 拒绝请求 / 撤回请求(文档 1.3.2 REMOVE)
|
||||
func (s *friendsService) removeFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error {
|
||||
targetUUID, err := s.resolveTargetUUID(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除 requester->target 记录(涵盖撤回发出的请求、删除好友)
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID); err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
} else if rel != nil {
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("删除关系失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝收到的请求时(requester 作为 target 方),删除 target->requester 的 pending 记录
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID); err != nil {
|
||||
return fmt.Errorf("查询反向关系失败: %w", err)
|
||||
} else if rel != nil && rel.Status == model.FriendsStatusPending {
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("拒绝请求失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTargetUUID 解析请求目标 UUID:profileId 优先,否则用 name 查 Profile
|
||||
func (s *friendsService) resolveTargetUUID(ctx context.Context, req FriendActionRequest) (string, error) {
|
||||
if req.ProfileID != "" {
|
||||
return utils.FormatUUIDToNoDash(req.ProfileID), nil
|
||||
}
|
||||
if req.Name != "" {
|
||||
profile, err := s.profileRepo.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return "", fmt.Errorf("查询目标档案失败: %w", err)
|
||||
}
|
||||
if profile == nil {
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return profile.UUID, nil
|
||||
}
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
|
||||
// toFriendDTOs 将 UUID 列表批量解析为 FriendDTO(补 name)
|
||||
func (s *friendsService) toFriendDTOs(ctx context.Context, uuids []string) ([]FriendDTO, error) {
|
||||
if len(uuids) == 0 {
|
||||
return []FriendDTO{}, nil
|
||||
}
|
||||
profiles, err := s.profileRepo.FindByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量查询档案失败: %w", err)
|
||||
}
|
||||
nameByUUID := make(map[string]string, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p != nil {
|
||||
nameByUUID[p.UUID] = p.Name
|
||||
}
|
||||
}
|
||||
result := make([]FriendDTO, 0, len(uuids))
|
||||
for _, uid := range uuids {
|
||||
result = append(result, FriendDTO{
|
||||
ProfileID: uid,
|
||||
Name: nameByUUID[uid], // 找不到 name 时留空
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 玩家偏好属性
|
||||
// ============================================================================
|
||||
|
||||
// GetAttributes 获取玩家属性(文档 1.2.1)
|
||||
func (s *friendsService) GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error) {
|
||||
attr, err := s.attrRepo.Get(ctx, profileUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询玩家偏好失败: %w", err)
|
||||
}
|
||||
if attr == nil {
|
||||
d := model.DefaultPlayerAttributes(profileUUID)
|
||||
attr = &d
|
||||
}
|
||||
return &PlayerAttributesResponse{
|
||||
ProfanityFilterPreferences: ProfanityFilterPreferencesResp{
|
||||
ProfanityFilterOn: attr.ProfanityFilterOn,
|
||||
},
|
||||
FriendsPreferences: FriendsPreferencesResp{
|
||||
Friends: enabledToString(attr.FriendsEnabled),
|
||||
AcceptInvites: enabledToString(attr.AcceptInvites),
|
||||
},
|
||||
ChatPreferences: ChatPreferencesResp{
|
||||
TextCommunication: attr.TextCommunication,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateAttributes 更新玩家偏好(文档 1.2.2)
|
||||
func (s *friendsService) UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error {
|
||||
// 先读取现有值(保留未传字段)
|
||||
attr, err := s.attrRepo.Get(ctx, profileUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询玩家偏好失败: %w", err)
|
||||
}
|
||||
if attr == nil {
|
||||
d := model.DefaultPlayerAttributes(profileUUID)
|
||||
attr = &d
|
||||
}
|
||||
|
||||
if req.ProfanityFilterPreferences != nil && req.ProfanityFilterPreferences.ProfanityFilterOn != nil {
|
||||
attr.ProfanityFilterOn = *req.ProfanityFilterPreferences.ProfanityFilterOn
|
||||
}
|
||||
if req.FriendsPreferences != nil {
|
||||
if req.FriendsPreferences.Friends != nil {
|
||||
attr.FriendsEnabled = *req.FriendsPreferences.Friends == "ENABLED"
|
||||
}
|
||||
if req.FriendsPreferences.AcceptInvites != nil {
|
||||
attr.AcceptInvites = *req.FriendsPreferences.AcceptInvites == "ENABLED"
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.attrRepo.Upsert(ctx, attr); err != nil {
|
||||
return fmt.Errorf("更新玩家偏好失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledToString 将布尔偏好转为 ENABLED/DISABLED 字符串
|
||||
func enabledToString(enabled bool) string {
|
||||
if enabled {
|
||||
return "ENABLED"
|
||||
}
|
||||
return "DISABLED"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 在线状态
|
||||
// ============================================================================
|
||||
|
||||
// UpdatePresence 上报自身在线状态并返回好友在线状态(文档 1.1)
|
||||
func (s *friendsService) UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error) {
|
||||
// 写入自身状态到 Redis
|
||||
key := PresenceKeyPrefix + requesterUUID
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
// 存储 "status|lastUpdated",便于读取时一并恢复时间戳
|
||||
value := status + "|" + now
|
||||
if err := s.redis.Set(ctx, key, value, s.presenceTTL); err != nil {
|
||||
s.logger.Error("写入在线状态失败",
|
||||
zap.Error(err),
|
||||
zap.String("profileUUID", requesterUUID),
|
||||
)
|
||||
return nil, fmt.Errorf("写入在线状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 拉取好友 UUID 列表
|
||||
friendUUIDs, err := s.friendRepo.ListAccepted(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询好友列表失败: %w", err)
|
||||
}
|
||||
|
||||
entries := s.batchGetPresence(ctx, friendUUIDs)
|
||||
// 文档 1.1:服务器仅返回非离线玩家
|
||||
filtered := make([]PresenceEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Status != "" && e.Status != PresenceStatusOffline {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return &PresenceResponse{Presence: filtered}, nil
|
||||
}
|
||||
|
||||
// batchGetPresence 批量读取多个 profileUUID 的在线状态
|
||||
func (s *friendsService) batchGetPresence(ctx context.Context, uuids []string) []PresenceEntry {
|
||||
if len(uuids) == 0 || s.redis == nil {
|
||||
return []PresenceEntry{}
|
||||
}
|
||||
keys := make([]string, 0, len(uuids))
|
||||
for _, uid := range uuids {
|
||||
keys = append(keys, PresenceKeyPrefix+uid)
|
||||
}
|
||||
// 使用底层 go-redis 客户端的 MGet 一次读取
|
||||
results, err := s.redis.Client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
s.logger.Warn("批量读取在线状态失败", zap.Error(err))
|
||||
return []PresenceEntry{}
|
||||
}
|
||||
|
||||
entries := make([]PresenceEntry, 0, len(uuids))
|
||||
for i, uid := range uuids {
|
||||
var entry PresenceEntry
|
||||
entry.ProfileID = uid
|
||||
if i < len(results) && results[i] != nil {
|
||||
if raw, ok := results[i].(string); ok && raw != "" {
|
||||
parts := strings.SplitN(raw, "|", 2)
|
||||
entry.Status = parts[0]
|
||||
if len(parts) == 2 {
|
||||
entry.LastUpdated = parts[1]
|
||||
} else {
|
||||
entry.LastUpdated = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 屏蔽列表
|
||||
// ============================================================================
|
||||
|
||||
// GetBlocklist 获取屏蔽列表(文档 1.6,含 120s 客户端节流缓存)
|
||||
func (s *friendsService) GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error) {
|
||||
// 文档 1.6:客户端 120s 节流;服务端在此也做一层缓存,避免重复查库
|
||||
cacheKey := BlocklistCacheKeyPrefix + blockerUUID
|
||||
if s.redis != nil {
|
||||
if cached, err := s.redis.GetBytes(ctx, cacheKey); err == nil && len(cached) > 0 {
|
||||
var resp BlockListResponse
|
||||
if err := json.Unmarshal(cached, &resp); err == nil {
|
||||
return &resp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocked, err := s.friendRepo.ListBlocked(ctx, blockerUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询屏蔽列表失败: %w", err)
|
||||
}
|
||||
if blocked == nil {
|
||||
blocked = []string{}
|
||||
}
|
||||
resp := &BlockListResponse{BlockedProfiles: blocked}
|
||||
|
||||
// 写入缓存
|
||||
if s.redis != nil {
|
||||
if data, err := json.Marshal(resp); err == nil {
|
||||
if err := s.redis.Set(ctx, cacheKey, data, s.blocklistTTL); err != nil {
|
||||
s.logger.Warn("缓存屏蔽列表失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 屏蔽/取消屏蔽方法(供后续/管理端调用,当前协议未定义对应端点)
|
||||
|
||||
// Block 屏蔽目标玩家
|
||||
func (s *friendsService) Block(ctx context.Context, blockerUUID, targetUUID string) error {
|
||||
if blockerUUID == targetUUID {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil)
|
||||
}
|
||||
// 若已有关系记录,更新为 blocked;否则新建
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID); err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
} else if rel != nil {
|
||||
if err := s.friendRepo.UpdateStatus(ctx, rel.ID, model.FriendsStatusBlocked); err != nil {
|
||||
return fmt.Errorf("更新屏蔽状态失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: blockerUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusBlocked,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("创建屏蔽记录失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
|
||||
// Unblock 取消屏蔽
|
||||
func (s *friendsService) Unblock(ctx context.Context, blockerUUID, targetUUID string) error {
|
||||
rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
}
|
||||
if rel == nil || rel.Status != model.FriendsStatusBlocked {
|
||||
// 幂等:未屏蔽时直接返回
|
||||
return nil
|
||||
}
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("取消屏蔽失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
|
||||
// invalidateBlocklistCache 清除屏蔽列表缓存
|
||||
func (s *friendsService) invalidateBlocklistCache(ctx context.Context, blockerUUID string) error {
|
||||
if s.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return s.redis.Del(ctx, BlocklistCacheKeyPrefix+blockerUUID)
|
||||
}
|
||||
|
||||
// 确保引用了 go-redis(MGet 通过 s.redis.Client)
|
||||
var _ = redis.Client{}
|
||||
260
internal/service/yggdrasil_friends_service_test.go
Normal file
260
internal/service/yggdrasil_friends_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user