262 lines
13 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 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,
}
}