按 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 ./... 全部通过
290 lines
11 KiB
Go
290 lines
11 KiB
Go
// Package service 定义业务逻辑层接口
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"carrotskin/internal/model"
|
||
"carrotskin/pkg/storage"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// UserService 用户服务接口
|
||
type UserService interface {
|
||
// 用户认证
|
||
Register(ctx context.Context, username, password, email, avatar string) (*model.User, string, error)
|
||
Login(ctx context.Context, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error)
|
||
|
||
// 用户查询
|
||
GetByID(ctx context.Context, id int64) (*model.User, error)
|
||
GetByEmail(ctx context.Context, email string) (*model.User, error)
|
||
GetByUsername(ctx context.Context, username string) (*model.User, error)
|
||
|
||
// 用户更新
|
||
UpdateInfo(ctx context.Context, user *model.User) error
|
||
UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error
|
||
ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error
|
||
ResetPassword(ctx context.Context, email, newPassword string) error
|
||
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
||
|
||
// 头像上传
|
||
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
|
||
|
||
// URL验证
|
||
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
||
|
||
// 配置获取
|
||
GetMaxProfilesPerUser() int
|
||
GetMaxTexturesPerUser() int
|
||
|
||
// 管理员操作
|
||
ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error)
|
||
SetRole(ctx context.Context, userID int64, role string) error
|
||
SetStatus(ctx context.Context, userID int64, status int16) error
|
||
}
|
||
|
||
// ProfileService 档案服务接口
|
||
type ProfileService interface {
|
||
// 档案CRUD
|
||
Create(ctx context.Context, userID int64, name string) (*model.Profile, error)
|
||
GetByUUID(ctx context.Context, uuid string) (*model.Profile, error)
|
||
GetByUserID(ctx context.Context, userID int64) ([]*model.Profile, error)
|
||
Update(ctx context.Context, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error)
|
||
Delete(ctx context.Context, uuid string, userID int64) error
|
||
|
||
// 档案状态
|
||
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
|
||
|
||
// 批量查询
|
||
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 材质服务接口
|
||
type TextureService interface {
|
||
// 材质CRUD
|
||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
|
||
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
||
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
|
||
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||
Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error)
|
||
Update(ctx context.Context, textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error)
|
||
Delete(ctx context.Context, textureID, uploaderID int64) error
|
||
|
||
// 收藏
|
||
ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error)
|
||
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||
|
||
// 限制检查
|
||
CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error
|
||
|
||
// 管理员操作
|
||
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error)
|
||
AdminDelete(ctx context.Context, textureID int64) error
|
||
IncrementDownload(ctx context.Context, textureID int64) error
|
||
}
|
||
|
||
// TokenService 令牌服务接口
|
||
type TokenService interface {
|
||
// 令牌管理
|
||
Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
|
||
CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
|
||
Validate(ctx context.Context, accessToken, clientToken string) bool
|
||
Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error)
|
||
Invalidate(ctx context.Context, accessToken string)
|
||
InvalidateUserTokens(ctx context.Context, userID int64)
|
||
|
||
// 令牌查询
|
||
GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error)
|
||
GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error)
|
||
}
|
||
|
||
// VerificationService 验证码服务接口
|
||
type VerificationService interface {
|
||
SendCode(ctx context.Context, email, codeType string) error
|
||
VerifyCode(ctx context.Context, email, code, codeType string) error
|
||
}
|
||
|
||
// CaptchaService 滑动验证码服务接口
|
||
type CaptchaService interface {
|
||
Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error)
|
||
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
|
||
CheckVerified(ctx context.Context, captchaID string) (bool, error)
|
||
ConsumeVerified(ctx context.Context, captchaID string) error
|
||
}
|
||
|
||
// YggdrasilService Yggdrasil服务接口
|
||
type YggdrasilService interface {
|
||
// 用户认证
|
||
GetUserIDByEmail(ctx context.Context, email string) (int64, error)
|
||
VerifyPassword(ctx context.Context, password string, userID int64) error
|
||
|
||
// 会话管理
|
||
JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error
|
||
HasJoinedServer(ctx context.Context, serverID, username, ip string) error
|
||
|
||
// 密码管理
|
||
ResetYggdrasilPassword(ctx context.Context, userID int64) (string, error)
|
||
|
||
// 序列化
|
||
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
|
||
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
|
||
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
|
||
|
||
// 证书
|
||
GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
|
||
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 {
|
||
// 登录安全
|
||
CheckLoginLocked(ctx context.Context, identifier string) (bool, time.Duration, error)
|
||
RecordLoginFailure(ctx context.Context, identifier string) (int, error)
|
||
ClearLoginAttempts(ctx context.Context, identifier string) error
|
||
GetRemainingLoginAttempts(ctx context.Context, identifier string) (int, error)
|
||
|
||
// 验证码安全
|
||
CheckVerifyLocked(ctx context.Context, email, codeType string) (bool, time.Duration, error)
|
||
RecordVerifyFailure(ctx context.Context, email, codeType string) (int, error)
|
||
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
||
}
|
||
|
||
// Services 服务集合
|
||
type Services struct {
|
||
User UserService
|
||
Profile ProfileService
|
||
Texture TextureService
|
||
Token TokenService
|
||
Verification VerificationService
|
||
Captcha CaptchaService
|
||
Yggdrasil YggdrasilService
|
||
Security SecurityService
|
||
Friends FriendsService
|
||
}
|
||
|
||
// ServiceDeps 服务依赖
|
||
type ServiceDeps struct {
|
||
Logger *zap.Logger
|
||
Storage *storage.StorageClient
|
||
}
|