Files
backend/internal/service/profile_service.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

333 lines
9.3 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 service
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strings"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
// profileService ProfileService的实现
type profileService struct {
profileRepo repository.ProfileRepository
userRepo repository.UserRepository
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
logger *zap.Logger
}
// NewProfileService 创建ProfileService实例
func NewProfileService(
profileRepo repository.ProfileRepository,
userRepo repository.UserRepository,
cacheManager *database.CacheManager,
logger *zap.Logger,
) ProfileService {
return &profileService{
profileRepo: profileRepo,
userRepo: userRepo,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger,
}
}
func (s *profileService) Create(ctx context.Context, userID int64, name string) (*model.Profile, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return nil, errors.New("用户不存在")
}
if user.Status != 1 {
return nil, errors.New("用户状态异常")
}
// 检查角色名是否已存在
existingName, err := s.profileRepo.FindByName(ctx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
}
if existingName != nil {
return nil, errors.New("角色名已被使用")
}
// 生成UUID和RSA密钥
profileUUID := utils.GenerateUUID()
privateKey, err := generateRSAPrivateKeyInternal()
if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err)
}
// 创建档案
profile := &model.Profile{
UUID: profileUUID,
UserID: userID,
Name: name,
RSAPrivateKey: privateKey,
}
if err := s.profileRepo.Create(ctx, profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err)
}
// 清除用户的 profile 列表缓存
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
return profile, nil
}
func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Profile, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.Profile(uuid)
var profile model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profile); ok {
return &profile, nil
}
// 缓存未命中,从数据库查询
profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
// 存入缓存(异步)
if profile2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, profile2, s.cache.Policy.ProfileTTL)
}
return profile2, nil
}
func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*model.Profile, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.ProfileList(userID)
var profiles []*model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profiles); ok {
return profiles, nil
}
// 缓存未命中,从数据库查询
profiles, err := s.profileRepo.FindByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("查询档案列表失败: %w", err)
}
// 存入缓存(异步)
if profiles != nil {
s.cache.SetAsync(context.Background(), cacheKey, profiles, s.cache.Policy.ProfileListTTL)
}
return profiles, nil
}
func (s *profileService) Update(ctx context.Context, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return nil, apperrors.ErrProfileNoPermission
}
// 检查角色名是否重复
if name != nil && *name != profile.Name {
existingName, err := s.profileRepo.FindByName(ctx, *name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
}
if existingName != nil {
return nil, errors.New("角色名已被使用")
}
profile.Name = *name
}
// 更新皮肤和披风
if skinID != nil {
profile.SkinID = skinID
}
if capeID != nil {
profile.CapeID = capeID
}
if err := s.profileRepo.Update(ctx, profile); err != nil {
return nil, fmt.Errorf("更新档案失败: %w", err)
}
// 清除该 profile 和用户列表的缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.Profile(uuid),
s.cacheKeys.ProfileList(userID),
)
return s.profileRepo.FindByUUID(ctx, uuid)
}
func (s *profileService) Delete(ctx context.Context, uuid string, userID int64) error {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.ErrProfileNotFound
}
return fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return apperrors.ErrProfileNoPermission
}
if err := s.profileRepo.Delete(ctx, uuid); err != nil {
return fmt.Errorf("删除档案失败: %w", err)
}
// 清除该 profile 和用户列表的缓存
s.cacheInv.OnDelete(ctx,
s.cacheKeys.Profile(uuid),
s.cacheKeys.ProfileList(userID),
)
return nil
}
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
count, err := s.profileRepo.CountByUserID(ctx, userID)
if err != nil {
return fmt.Errorf("查询档案数量失败: %w", err)
}
if int(count) >= maxProfiles {
return fmt.Errorf("已达到档案数量上限(%d个", maxProfiles)
}
return nil
}
func (s *profileService) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
profiles, err := s.profileRepo.GetByNames(ctx, names)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
}
return profiles, nil
}
func (s *profileService) GetByProfileName(ctx context.Context, name string) (*model.Profile, error) {
// Profile name 查询通常不会频繁缓存,但为了一致性也添加
profile, err := s.profileRepo.FindByName(ctx, name)
if err != nil {
return nil, errors.New("用户角色未创建")
}
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)
if err != nil {
return "", err
}
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
return string(privateKeyPEM), nil
}