package user import ( "context" "database/sql" "errors" "strings" "time" "hyapp/pkg/appcode" userdomain "hyapp/services/user-service/internal/domain/user" ) // GetAvatarUpload 按完整租户主键读取上传凭证;PRIMARY KEY 保证资料更新和上传重试不会扫描媒体表。 func (r *Repository) GetAvatarUpload(ctx context.Context, userID int64, uploadID string) (userdomain.AvatarUploadRegistration, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, user_id, avatar_upload_id, client_command_id, media_payload_sha256, expected_object_key, object_url, status, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, sha256, created_at_ms, updated_at_ms FROM user_avatar_upload_registrations WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ? LIMIT 1`, appcode.FromContext(ctx), userID, strings.TrimSpace(uploadID)) registration, err := scanAvatarUpload(row) if errors.Is(err, sql.ErrNoRows) { return userdomain.AvatarUploadRegistration{}, false, nil } return registration, err == nil, err } // RegisterAvatarUpload 原子占用客户端 command_id 派生的凭证;并发重试只允许完全相同的媒体载荷。 func (r *Repository) RegisterAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error) { registration.AppCode = appcode.FromContext(ctx) registration.UploadID = strings.TrimSpace(registration.UploadID) registration.ClientCommandID = strings.TrimSpace(registration.ClientCommandID) registration.MediaPayloadSHA256 = strings.ToLower(strings.TrimSpace(registration.MediaPayloadSHA256)) registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/") registration.ObjectURL, registration.Status = "", "authorized" if registration.CreatedAtMS <= 0 { registration.CreatedAtMS = time.Now().UTC().UnixMilli() } if registration.UpdatedAtMS <= 0 { registration.UpdatedAtMS = registration.CreatedAtMS } _, err := r.db.ExecContext(ctx, ` INSERT INTO user_avatar_upload_registrations ( app_code, user_id, avatar_upload_id, client_command_id, media_payload_sha256, expected_object_key, object_url, status, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, sha256, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, '', 'authorized', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE avatar_upload_id = user_avatar_upload_registrations.avatar_upload_id`, registration.AppCode, registration.UserID, registration.UploadID, registration.ClientCommandID, registration.MediaPayloadSHA256, registration.ExpectedObjectKey, registration.Media.ContentType, registration.Media.Format, registration.Media.SizeBytes, registration.Media.Width, registration.Media.Height, registration.Media.Animated, registration.Media.FrameCount, registration.Media.DurationMS, registration.Media.SHA256, registration.CreatedAtMS, registration.UpdatedAtMS) if err != nil { return userdomain.AvatarUploadRegistration{}, err } saved, exists, err := r.GetAvatarUpload(ctx, registration.UserID, registration.UploadID) if err != nil { return userdomain.AvatarUploadRegistration{}, err } if !exists || !sameAvatarUploadRecord(saved, registration) { return userdomain.AvatarUploadRegistration{}, userdomain.ErrAvatarUploadCommandConflict } return saved, nil } // CompleteAvatarUpload 只允许 authorized -> active;并发完成以首次受信 COS URL 为准。 func (r *Repository) CompleteAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error) { registration.AppCode = appcode.FromContext(ctx) registration.ObjectURL = strings.TrimSpace(registration.ObjectURL) registration.UpdatedAtMS = time.Now().UTC().UnixMilli() _, err := r.db.ExecContext(ctx, ` UPDATE user_avatar_upload_registrations SET object_url = ?, status = 'active', updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ? AND status = 'authorized'`, registration.ObjectURL, registration.UpdatedAtMS, registration.AppCode, registration.UserID, registration.UploadID) if err != nil { return userdomain.AvatarUploadRegistration{}, err } saved, exists, err := r.GetAvatarUpload(ctx, registration.UserID, registration.UploadID) if err != nil { return userdomain.AvatarUploadRegistration{}, err } if !exists || !sameAvatarUploadRecord(saved, registration) || saved.Status != "active" || saved.ObjectURL != registration.ObjectURL { return userdomain.AvatarUploadRegistration{}, userdomain.ErrAvatarUploadCommandConflict } return saved, nil } func scanAvatarUpload(scanner interface{ Scan(dest ...any) error }) (userdomain.AvatarUploadRegistration, error) { var registration userdomain.AvatarUploadRegistration err := scanner.Scan( ®istration.AppCode, ®istration.UserID, ®istration.UploadID, ®istration.ClientCommandID, ®istration.MediaPayloadSHA256, ®istration.ExpectedObjectKey, ®istration.ObjectURL, ®istration.Status, ®istration.Media.ContentType, ®istration.Media.Format, ®istration.Media.SizeBytes, ®istration.Media.Width, ®istration.Media.Height, ®istration.Media.Animated, ®istration.Media.FrameCount, ®istration.Media.DurationMS, ®istration.Media.SHA256, ®istration.CreatedAtMS, ®istration.UpdatedAtMS, ) if err != nil { return userdomain.AvatarUploadRegistration{}, err } registration.Media.URL, registration.Media.ObjectKey = registration.ObjectURL, registration.ExpectedObjectKey registration.Media.Status = registration.Status return registration, nil } func sameAvatarUploadRecord(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.TrimSpace(left.ClientCommandID) == strings.TrimSpace(right.ClientCommandID) && strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) && strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/") && strings.EqualFold(left.Media.ContentType, right.Media.ContentType) && strings.EqualFold(left.Media.Format, right.Media.Format) && left.Media.SizeBytes == right.Media.SizeBytes && left.Media.Width == right.Media.Width && left.Media.Height == right.Media.Height && left.Media.Animated == right.Media.Animated && left.Media.FrameCount == right.Media.FrameCount && left.Media.DurationMS == right.Media.DurationMS && strings.EqualFold(left.Media.SHA256, right.Media.SHA256) }