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