package user import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/appcode" "hyapp/pkg/giftlimits" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" ) const ( animatedAvatarBenefitCode = "animated_avatar" tieredPrivilegeVIPProgram = "tiered_privilege_v1" avatarUploadStatusActive = "active" avatarUploadMaxBytes = int64(5 << 20) avatarUploadMaxFrames = int32(120) avatarUploadMaxDurationMS = int64(15_000) avatarVIPLookupTimeout = time.Second ) // AuthorizeUserAvatarUpload 在对象写入前持久化媒体事实,并只对服务端解析出的动态 GIF/WebP 查询精确权益。 func (s *Service) AuthorizeUserAvatarUpload(ctx context.Context, requestID string, userID int64, commandID string, media userdomain.AvatarMedia) (userdomain.AvatarUploadRegistration, error) { if userID <= 0 || !giftlimits.ValidCommandID(strings.TrimSpace(commandID)) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.InvalidArgument, "avatar upload request is invalid") } if s.avatarUploadRepository == nil { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Unavailable, "avatar upload repository is unavailable") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 注册头像仍走 public 静态入口;VIP 动态头像只能由已完成资料且已登录的用户上传。 return userdomain.AvatarUploadRegistration{}, err } media = normalizeAvatarMedia(media) if err := validateAvatarMedia(media, false); err != nil { return userdomain.AvatarUploadRegistration{}, err } appCode := appcode.FromContext(ctx) uploadID := deriveAvatarUploadID(appCode, userID, commandID) payloadSHA := avatarMediaPayloadSHA256(media) registration := userdomain.AvatarUploadRegistration{ AppCode: appCode, UserID: userID, UploadID: uploadID, ClientCommandID: strings.TrimSpace(commandID), MediaPayloadSHA256: payloadSHA, Media: media, } registration.ExpectedObjectKey = avatarObjectKey(appCode, userID, uploadID, media) if existing, exists, err := s.avatarUploadRepository.GetAvatarUpload(ctx, userID, uploadID); err != nil { return userdomain.AvatarUploadRegistration{}, err } else if exists { if !sameAvatarUploadIdentity(existing, registration) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload command payload conflict") } if existing.Status == avatarUploadStatusActive { // 已完成上传的同载荷重试返回首次结果,不能因用户 VIP 后续变化破坏成功请求的幂等性。 return existing, nil } if existing.Status != "authorized" { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Conflict, "avatar upload is already consumed") } } if media.Animated { if err := s.requireAnimatedAvatarBenefit(ctx, requestID, userID, "upload_animated_avatar"); err != nil { return userdomain.AvatarUploadRegistration{}, err } } nowMS := s.now().UnixMilli() registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS saved, err := s.avatarUploadRepository.RegisterAvatarUpload(ctx, registration) if errors.Is(err, userdomain.ErrAvatarUploadCommandConflict) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload command payload conflict") } return saved, err } // CompleteUserAvatarUpload 只推进首次授权的对象键;URL 和媒体属性必须与授权载荷逐字段一致。 func (s *Service) CompleteUserAvatarUpload(ctx context.Context, userID int64, uploadID string, media userdomain.AvatarMedia) (userdomain.AvatarUploadRegistration, error) { uploadID = strings.TrimSpace(uploadID) if userID <= 0 || !giftlimits.ValidCommandID(uploadID) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.InvalidArgument, "avatar upload completion is invalid") } if s.avatarUploadRepository == nil { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Unavailable, "avatar upload repository is unavailable") } media = normalizeAvatarMedia(media) if err := validateAvatarMedia(media, true); err != nil { return userdomain.AvatarUploadRegistration{}, err } existing, exists, err := s.avatarUploadRepository.GetAvatarUpload(ctx, userID, uploadID) if err != nil { return userdomain.AvatarUploadRegistration{}, err } if !exists { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.NotFound, "avatar upload authorization not found") } candidate := userdomain.AvatarUploadRegistration{ AppCode: appcode.FromContext(ctx), UserID: userID, UploadID: uploadID, MediaPayloadSHA256: avatarMediaPayloadSHA256(media), ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media, } if !sameAvatarUploadIdentity(existing, candidate) || !sameAvatarMediaProperties(existing.Media, media) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload payload conflict") } if existing.Status == avatarUploadStatusActive { if strings.TrimSpace(existing.ObjectURL) != media.URL { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload completion conflict") } return existing, nil } if existing.Status != "authorized" { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Conflict, "avatar upload is already consumed") } existing.ObjectURL, existing.Media.URL, existing.Media.ObjectKey = media.URL, media.URL, media.ObjectKey existing.Status, existing.Media.Status, existing.UpdatedAtMS = avatarUploadStatusActive, avatarUploadStatusActive, s.now().UnixMilli() completed, err := s.avatarUploadRepository.CompleteAvatarUpload(ctx, existing) if errors.Is(err, userdomain.ErrAvatarUploadCommandConflict) { return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload completion conflict") } return completed, err } // requireAnimatedAvatarBenefit 只对 wallet 配置为权益制的 App 执行动态头像门禁。 // Fami 当前所需等级由 Wallet benefit 配置返回,user-service 不比较 vip_level。 func (s *Service) requireAnimatedAvatarBenefit(ctx context.Context, requestID string, userID int64, action string) error { if s.vipBenefitDecisionChecker == nil { return xerr.New(xerr.Unavailable, "wallet vip benefit checker is unavailable") } lookupCtx, cancel := context.WithTimeout(ctx, avatarVIPLookupTimeout) defer cancel() decision, err := s.vipBenefitDecisionChecker.CheckVIPBenefitDecision(lookupCtx, strings.TrimSpace(requestID), userID, animatedAvatarBenefitCode) if err != nil { return xerr.New(xerr.Unavailable, "check vip benefit failed") } if strings.TrimSpace(decision.ProgramType) != tieredPrivilegeVIPProgram { return nil } if decision.Allowed { return nil } return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{ "benefit_code": animatedAvatarBenefitCode, "required_level": fmt.Sprintf("%d", decision.RequiredLevel), "current_effective_level": fmt.Sprintf("%d", decision.CurrentEffectiveLevel), "action": strings.TrimSpace(action), }) } // avatarUploadRequiredForRawURL 判断当前 App 是否禁止直接写非空头像 URL。 // nil checker 仅兼容尚未装配 Wallet 的旧测试/本地构造;生产启用 Wallet 后,权益制 App 必须提交上传凭证。 func (s *Service) avatarUploadRequiredForRawURL(ctx context.Context, requestID string, userID int64) (bool, error) { if s.vipBenefitDecisionChecker == nil { return false, nil } lookupCtx, cancel := context.WithTimeout(ctx, avatarVIPLookupTimeout) defer cancel() decision, err := s.vipBenefitDecisionChecker.CheckVIPBenefitDecision(lookupCtx, strings.TrimSpace(requestID), userID, animatedAvatarBenefitCode) if err != nil { return false, xerr.New(xerr.Unavailable, "check vip program failed") } return strings.TrimSpace(decision.ProgramType) == tieredPrivilegeVIPProgram, nil } func validateAvatarMedia(media userdomain.AvatarMedia, requireUploaded bool) error { if media.SizeBytes <= 0 || media.SizeBytes > avatarUploadMaxBytes || media.Width <= 0 || media.Height <= 0 || media.Width > 4096 || media.Height > 4096 || int64(media.Width)*int64(media.Height) > 16_777_216 || media.FrameCount <= 0 || media.FrameCount > avatarUploadMaxFrames || len(media.SHA256) != 64 { return xerr.New(xerr.InvalidArgument, "avatar media properties are invalid") } validType := (media.Format == "jpeg" && media.ContentType == "image/jpeg") || (media.Format == "png" && media.ContentType == "image/png") || (media.Format == "gif" && media.ContentType == "image/gif") || (media.Format == "webp" && media.ContentType == "image/webp") if !validType || (media.Animated && media.Format != "gif" && media.Format != "webp") { return xerr.New(xerr.InvalidArgument, "avatar media format is invalid") } if media.Animated { if media.FrameCount < 2 || media.DurationMS <= 0 || media.DurationMS > avatarUploadMaxDurationMS { return xerr.New(xerr.InvalidArgument, "avatar animation properties are invalid") } } else if media.FrameCount != 1 || media.DurationMS != 0 { return xerr.New(xerr.InvalidArgument, "static avatar properties are invalid") } if requireUploaded { if media.URL == "" || media.ObjectKey == "" || media.Status != avatarUploadStatusActive { return xerr.New(xerr.InvalidArgument, "uploaded avatar media is incomplete") } } else if media.URL != "" || media.ObjectKey != "" || media.Status != avatarUploadStatusActive { return xerr.New(xerr.InvalidArgument, "avatar authorization requires pre-upload metadata") } return nil } func normalizeAvatarMedia(media userdomain.AvatarMedia) userdomain.AvatarMedia { media.URL = strings.TrimSpace(media.URL) media.ObjectKey = strings.TrimLeft(strings.TrimSpace(media.ObjectKey), "/") media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType)) media.Format = strings.ToLower(strings.TrimSpace(media.Format)) media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256)) media.Status = strings.ToLower(strings.TrimSpace(media.Status)) return media } func deriveAvatarUploadID(appCode string, userID int64, commandID string) string { sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", appcode.Normalize(appCode), userID, strings.TrimSpace(commandID)))) return "avatar_" + hex.EncodeToString(sum[:16]) } func avatarObjectKey(appCode string, userID int64, uploadID string, media userdomain.AvatarMedia) string { extension := media.Format if extension == "jpeg" { extension = "jpg" } return fmt.Sprintf("user-media/%s/avatars/%d/%s_%s.%s", appcode.Normalize(appCode), userID, uploadID, media.SHA256[:16], extension) } func avatarMediaPayloadSHA256(media userdomain.AvatarMedia) string { value := fmt.Sprintf("%s|%s|%d|%d|%d|%t|%d|%d|%s", media.ContentType, media.Format, media.SizeBytes, media.Width, media.Height, media.Animated, media.FrameCount, media.DurationMS, media.SHA256) sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func sameAvatarUploadIdentity(left userdomain.AvatarUploadRegistration, right userdomain.AvatarUploadRegistration) bool { return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) && left.UserID == right.UserID && strings.TrimSpace(left.UploadID) == strings.TrimSpace(right.UploadID) && strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) && strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/") } func sameAvatarMediaProperties(left userdomain.AvatarMedia, right userdomain.AvatarMedia) bool { left, right = normalizeAvatarMedia(left), normalizeAvatarMedia(right) return left.ObjectKey == right.ObjectKey && left.ContentType == right.ContentType && left.Format == right.Format && left.SizeBytes == right.SizeBytes && left.Width == right.Width && left.Height == right.Height && left.Animated == right.Animated && left.FrameCount == right.FrameCount && left.DurationMS == right.DurationMS && left.SHA256 == right.SHA256 } // AvatarMediaFromProto 把内部 gRPC contract 转成领域媒体事实。 func AvatarMediaFromProto(media *userv1.UserAvatarMedia) userdomain.AvatarMedia { if media == nil { return userdomain.AvatarMedia{} } return userdomain.AvatarMedia{ URL: media.GetUrl(), ObjectKey: media.GetObjectKey(), ContentType: media.GetContentType(), Format: media.GetFormat(), SizeBytes: media.GetSizeBytes(), Width: media.GetWidth(), Height: media.GetHeight(), Animated: media.GetAnimated(), FrameCount: media.GetFrameCount(), DurationMS: media.GetDurationMs(), SHA256: media.GetSha256(), Status: media.GetStatus(), } } // AvatarMediaToProto 返回已由 user owner 接受的媒体事实。 func AvatarMediaToProto(media userdomain.AvatarMedia) *userv1.UserAvatarMedia { return &userv1.UserAvatarMedia{ Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount, DurationMs: media.DurationMS, Sha256: media.SHA256, Status: media.Status, } }