Files
backend/internal/repository/friends_repository.go
lan f2b02682c2
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
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 ./... 全部通过
2026-07-09 20:44:41 +08:00

213 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"context"
"time"
"carrotskin/internal/model"
"gorm.io/gorm"
)
// FriendRepository 好友/屏蔽关系仓储接口
type FriendRepository interface {
// Create 创建好友/屏蔽关系记录
Create(ctx context.Context, friend *model.Friend) error
// FindRelation 查询 requester->target 方向的关系记录
FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error)
// ListAccepted 返回与 uuid 已互为好友的对端 UUID 集合
// (合并 requester 与 target 两个方向 status=accepted 的记录)
ListAccepted(ctx context.Context, uuid string) ([]string, error)
// ListIncoming 返回收到的 pending 请求发起者 UUID 列表
ListIncoming(ctx context.Context, uuid string) ([]string, error)
// ListOutgoing 返回已发出的 pending 请求目标 UUID 列表
ListOutgoing(ctx context.Context, uuid string) ([]string, error)
// UpdateStatus 更新某条记录的状态
UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error
// Delete 删除记录(物理删除,用于 remove/decline/revoke/unblock
Delete(ctx context.Context, id int64) error
// ListBlocked 返回 blocker 屏蔽的 target UUID 列表
ListBlocked(ctx context.Context, blockerUUID string) ([]string, error)
}
// PlayerAttributeRepository 玩家偏好属性仓储接口
type PlayerAttributeRepository interface {
// Get 读取玩家偏好;不存在时返回 model.DefaultPlayerAttributes 的零值结构,错误为 nil
Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error)
// Upsert 插入或更新玩家偏好
Upsert(ctx context.Context, attr *model.PlayerAttributes) error
}
// friendRepository FriendRepository 的 GORM 实现
type friendRepository struct {
db *gorm.DB
}
// NewFriendRepository 创建 FriendRepository 实例
func NewFriendRepository(db *gorm.DB) FriendRepository {
return &friendRepository{db: db}
}
func (r *friendRepository) Create(ctx context.Context, friend *model.Friend) error {
return r.db.WithContext(ctx).Create(friend).Error
}
func (r *friendRepository) FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error) {
var f model.Friend
err := r.db.WithContext(ctx).
Where("requester_uuid = ? AND target_uuid = ?", requesterUUID, targetUUID).
First(&f).Error
return handleNotFoundResult(&f, err)
}
func (r *friendRepository) ListAccepted(ctx context.Context, uuid string) ([]string, error) {
var records []model.Friend
// requester 方向
if err := r.db.WithContext(ctx).
Select("target_uuid").
Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted).
Find(&records).Error; err != nil {
return nil, err
}
result := make([]string, 0, len(records))
for _, r := range records {
result = append(result, r.TargetUUID)
}
// target 方向
records = records[:0]
if err := r.db.WithContext(ctx).
Select("requester_uuid").
Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted).
Find(&records).Error; err != nil {
return nil, err
}
for _, r := range records {
result = append(result, r.RequesterUUID)
}
return result, nil
}
func (r *friendRepository) ListIncoming(ctx context.Context, uuid string) ([]string, error) {
var records []model.Friend
err := r.db.WithContext(ctx).
Select("requester_uuid").
Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusPending).
Find(&records).Error
if err != nil {
return nil, err
}
result := make([]string, 0, len(records))
for _, r := range records {
result = append(result, r.RequesterUUID)
}
return result, nil
}
func (r *friendRepository) ListOutgoing(ctx context.Context, uuid string) ([]string, error) {
var records []model.Friend
err := r.db.WithContext(ctx).
Select("target_uuid").
Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusPending).
Find(&records).Error
if err != nil {
return nil, err
}
result := make([]string, 0, len(records))
for _, r := range records {
result = append(result, r.TargetUUID)
}
return result, nil
}
func (r *friendRepository) UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error {
return r.db.WithContext(ctx).Model(&model.Friend{}).
Where("id = ?", id).
Update("status", status).Error
}
func (r *friendRepository) Delete(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Delete(&model.Friend{}, id).Error
}
func (r *friendRepository) ListBlocked(ctx context.Context, blockerUUID string) ([]string, error) {
var records []model.Friend
err := r.db.WithContext(ctx).
Select("target_uuid").
Where("requester_uuid = ? AND status = ?", blockerUUID, model.FriendsStatusBlocked).
Find(&records).Error
if err != nil {
return nil, err
}
result := make([]string, 0, len(records))
for _, r := range records {
result = append(result, r.TargetUUID)
}
return result, nil
}
// playerAttributeRepository PlayerAttributeRepository 的 GORM 实现
type playerAttributeRepository struct {
db *gorm.DB
}
// NewPlayerAttributeRepository 创建 PlayerAttributeRepository 实例
func NewPlayerAttributeRepository(db *gorm.DB) PlayerAttributeRepository {
return &playerAttributeRepository{db: db}
}
func (r *playerAttributeRepository) Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error) {
var attr model.PlayerAttributes
err := r.db.WithContext(ctx).
Where("profile_uuid = ?", profileUUID).
First(&attr).Error
if err != nil {
if IsNotFound(err) {
// 不存在时返回默认值
d := model.DefaultPlayerAttributes(profileUUID)
return &d, nil
}
return nil, err
}
return &attr, nil
}
func (r *playerAttributeRepository) Upsert(ctx context.Context, attr *model.PlayerAttributes) error {
now := time.Now()
attr.UpdatedAt = now
// 用 map 显式写出所有字段,规避 gorm 对带 default 标签的零值布尔字段在 Create 时被忽略的问题
fields := map[string]interface{}{
"friends_enabled": attr.FriendsEnabled,
"accept_invites": attr.AcceptInvites,
"profanity_filter_on": attr.ProfanityFilterOn,
"text_communication": attr.TextCommunication,
"updated_at": now,
}
// 优先 UPDATE若影响行数为 0记录不存在再 INSERT
res := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).
Where("profile_uuid = ?", attr.ProfileUUID).
Updates(fields)
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
return nil
}
// 不存在 -> 插入;用 map 写入避免零值布尔被 default 覆盖
createFields := map[string]interface{}{
"profile_uuid": attr.ProfileUUID,
"friends_enabled": attr.FriendsEnabled,
"accept_invites": attr.AcceptInvites,
"profanity_filter_on": attr.ProfanityFilterOn,
"text_communication": attr.TextCommunication,
"updated_at": now,
"created_at": now,
}
if err := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).Create(createFields).Error; err != nil {
// 并发情况下可能唯一键冲突,回退为更新一次
return r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).
Where("profile_uuid = ?", attr.ProfileUUID).
Updates(fields).Error
}
return nil
}