コンテンツにスキップ

クリーンアーキテクチャ設計書

項目
モジュール/サービス名 Storage Service (recerdo-storage)
作成者 Akira
作成日 2026-04-13
ステータス ドラフト
バージョン 1.0

1. 概要

1.1 目的

Storage Service はRecuerdo プラットフォームにおいて、すべてのメディア(写真・動画)の受け入れ、変換、配信を一元管理するマイクロサービスである。ユーザーアップロード、形式変換(HEIC→JPEG/WebP、MP4→HLS)、サムネイル生成、Live Photo ペアリング、アクセス制御付き配信を責務とする。

インフラ方針(ポリシー適用):

  • Beta: Object Storage = Garage(S3互換OSS)、Queue = Redis + BullMQ/asynq、DB = MySQL 8 (MariaDB互換)
  • Prod: Object Storage = OCI Object Storage、Queue = OCI Queue、DB = OCI MySQL HeatWave (MariaDB互換SQLに限定)
  • Media Transcoder: FFmpeg(HLS: 360p/720p/1080p, 6s セグメント)、libheif(HEIC→JPEG/WebP)
  • **ハイライトビデオはユーザー指定の素材のみ**を結合し、ML/自動生成は行わない
  • AWS は Cognito のみ(S3/SQS/SNS/SES/DynamoDB/CloudFront は利用しない)

1.2 ビジネスコンテキスト

  • Recuerdo はメモリ共有がコアのプラットフォーム:写真・動画は中核資産
  • 形式多様性対応:HEIC(Apple)、JPEG、PNG、MP4 など
  • アクセス管理:プライベート(自分のみ)、組織メンバー共有、イベント参加者共有
  • パフォーマンス:大量アップロード対応、チャンク式アップロード、キャッシュ・CDN 活用

1.3 アーキテクチャ原則

  • 非同期処理パイプライン:Upload → QueuePort (Beta: Redis+BullMQ/asynq, Prod: OCI Queue) → Processing Workers → 最適化ファイル出力
  • アクセス制御:アップロード時に access_policy 設定、配信時に権限チェック
  • ドメイン駆動:MediaFile、MediaChunk、ProcessingJob エンティティでビジネスロジック表現
  • ストレージ最適化:オブジェクトストレージ の raw/optimized/thumb フォルダ分離、presigned URL での帯域節約

2. レイヤーアーキテクチャ

2.1 アーキテクチャ図 (ASCII concentric circles)

┌─────────────────────────────────────────────────────┐
│  フレームワーク&ドライバ層                          │
│  (Web: Gin, Storage: Garage/OCI OSS, Queue:       │
│   Redis BullMQ/asynq or OCI Queue,                │
│   Transcode: FFmpeg + libheif)                    │
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  インターフェースアダプタ層                        │
│  (HTTP Handler, Repository Impl,                  │
│   GarageStorageAdapter/OCIObjectStorageAdapter,   │
│   RedisBullMQAdapter/OCIQueueAdapter,             │
│   FFmpegHLSAdapter, LibheifImageAdapter)          │
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  ユースケース層 (アプリケーション)                │
│  (UploadMediaSingle, InitChunkedUpload,           │
│   TranscodeVideoToHLS, ConvertHEICToOptimized,    │
│   HandleLivePhotoPair, ProcessMedia, DeliverMedia)│
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  エンティティ層 (ドメイン)                        │
│  (MediaFile, MediaChunk, ProcessingJob,           │
│   MediaStatus, MimeType, AccessPolicy)            │
└─────────────────────────────────────────────────────┘

2.2 依存性ルール

  • 内側への依存のみ:Adapter → UseCase → Domain
  • 外部ストレージはホスト扱い:オブジェクトストレージはインフラ層(外側)、ドメインは依存しない
  • ポート経由アクセス:オブジェクトストレージ 操作、Job Queue 制御はポート(インターフェース)経由

3. エンティティ層(ドメイン)

3.1 ドメインモデル

エンティティ 説明
MediaFile アップロードされたメディアファイルの中核:メタデータ、ストレージ位置、処理ステータス
MediaChunk チャンク式アップロード時の分割データ:チャンクインデックス、オブジェクトストレージ 位置
ProcessingJob 非同期処理ジョブの追跡:メディア 変換、サムネイル生成

3.2 値オブジェクト

値オブジェクト 許可される値 制約
MediaStatus UPLOADING, PROCESSING, READY, FAILED 遷移ルール有り
MimeType image/jpeg, image/png, image/heic, video/mp4 許可リスト検証
StorageKey Media path: {org_id}/{media_id}/{type} フォーマット検証
AccessPolicy PRIVATE, ORG_MEMBERS, EVENT_MEMBERS イミュータブル
DeliveryType original, optimized, thumb 形式別配信

3.3 ドメインルール / 不変条件

  • Access Control Immutability:access_policy は作成時に確定、変更不可
  • File Size Limits:画像 max 100MB、動画 max 500MB(ドメイン層で検証)
  • HEIC Auto-Conversion:HEIC→JPEG/WebP は libheif による自動変換、必須(ユーザー選択不可)
  • Video Auto-Conversion:MP4→HLS(360p/720p/1080p, 6秒セグメント)は FFmpeg による自動変換、必須
  • Live Photo Pairing:iOS Live Photo は HEIC + MOV を asset_identifier で同一ペアとして保存
  • Thumbnail Generation:すべてのメディアが ≤1280px サムネイル生成
  • Chunk Expiry:チャンク式アップロードは24時間で自動失効
  • FAILED Status Block:FAILED ステータスはアクセス拒否、再アップロード必須
  • Only Uploader or Admin Can Delete:削除権限は厳格(uploader か org admin のみ)

3.4 ドメインイベント

イベント トリガー ペイロード 購読者
MediaUploaded UploadMediaSingle 成功 media_id, org_id, uploader_id, mime_type Album Service (参照可能化)
MediaReady ProcessMedia(最適化)完了 media_id, storage_key, delivery_type Album Service, Timeline Service
MediaProcessingFailed Queue job 失敗 media_id, error_msg Notification Service (通知)

3.5 エンティティ定義 (Go pseudocode)

package domain

import (
    "time"
)

// MediaStatus メディアのライフサイクルステータス
type MediaStatus string

const (
    MediaStatusUploading   MediaStatus = "UPLOADING"   // アップロード中
    MediaStatusProcessing  MediaStatus = "PROCESSING"  // 処理中(HEIC→JPEG/WebP、HLSトランスコード、サムネイル生成)
    MediaStatusReady       MediaStatus = "READY"       // 配信可能
    MediaStatusFailed      MediaStatus = "FAILED"      // 処理失敗、アクセス不可
)

// MimeType メディアの MIME タイプ(ホワイトリスト)
type MimeType string

const (
    MimeTypeJPEG  MimeType = "image/jpeg"
    MimeTypePNG   MimeType = "image/png"
    MimeTypeHEIC  MimeType = "image/heic"
    MimeTypeMP4   MimeType = "video/mp4"
)

// IsImageType 画像タイプか判定
func (mt MimeType) IsImageType() bool {
    switch mt {
    case MimeTypeJPEG, MimeTypePNG, MimeTypeHEIC:
        return true
    default:
        return false
    }
}

// IsVideoType 動画タイプか判定
func (mt MimeType) IsVideoType() bool {
    return mt == MimeTypeMP4
}

// AccessPolicy ファイルのアクセス権レベル
type AccessPolicy string

const (
    AccessPolicyPrivate      AccessPolicy = "PRIVATE"       // 所有者のみ
    AccessPolicyOrgMembers   AccessPolicy = "ORG_MEMBERS"   // 組織メンバー
    AccessPolicyEventMembers AccessPolicy = "EVENT_MEMBERS" // イベント参加者
)

// StorageKey オブジェクトストレージ (Garage / OCI Object Storage) 格納位置を管理する値オブジェクト
type StorageKey struct {
    value string // org_id/media_id/type
}

func NewStorageKey(orgID, mediaID, storageType string) StorageKey {
    return StorageKey{
        value: fmt.Sprintf("%s/%s/%s", orgID, mediaID, storageType),
    }
}

func (sk StorageKey) String() string {
    return sk.value
}

// DeliveryType 配信時のファイルタイプ
type DeliveryType string

const (
    DeliveryTypeOriginal   DeliveryType = "original"   // 元のファイル
    DeliveryTypeOptimized  DeliveryType = "optimized"  // Web 最適化版(PNG 圧縮など)
    DeliveryTypeThumbnail  DeliveryType = "thumb"      // サムネイル
)

// MediaFile ドメインエンティティ
type MediaFile struct {
    ID               string
    OrgID            string
    UploaderID       string
    OriginalFilename string
    MimeType         MimeType
    FileSizeBytes    int64
    StorageKey       StorageKey           // raw/ フォルダ
    Status           MediaStatus
    AccessPolicy     AccessPolicy
    CreatedAt        time.Time
    UpdatedAt        time.Time
    domainEvents     []interface{}
}

// NewMediaFile ファクトリメソッド
func NewMediaFile(
    orgID, uploaderID, filename string,
    mimeType MimeType,
    fileSize int64,
    policy AccessPolicy,
) (*MediaFile, error) {
    // ファイルサイズ検証
    if !isValidFileSize(mimeType, fileSize) {
        return nil, fmt.Errorf("file size exceeds limit: %d bytes", fileSize)
    }

    // MIME タイプ検証
    if !isAllowedMimeType(mimeType) {
        return nil, fmt.Errorf("unsupported mime type: %s", mimeType)
    }

    mediaID := generateULID()

    mf := &MediaFile{
        ID:               mediaID,
        OrgID:            orgID,
        UploaderID:       uploaderID,
        OriginalFilename: filename,
        MimeType:         mimeType,
        FileSizeBytes:    fileSize,
        StorageKey:       NewStorageKey(orgID, mediaID, "raw"),
        Status:           MediaStatusUploading,
        AccessPolicy:     policy,
        CreatedAt:        time.Now(),
        UpdatedAt:        time.Now(),
    }

    mf.recordEvent(&MediaUploadedEvent{
        MediaID:    mediaID,
        OrgID:      orgID,
        UploaderID: uploaderID,
        MimeType:   string(mimeType),
        FileSize:   fileSize,
    })

    return mf, nil
}

// StartProcessing ステータス遷移:UPLOADING → PROCESSING
func (mf *MediaFile) StartProcessing() error {
    if mf.Status != MediaStatusUploading {
        return fmt.Errorf("only UPLOADING files can start processing")
    }
    mf.Status = MediaStatusProcessing
    mf.UpdatedAt = time.Now()
    return nil
}

// MarkReady 処理完了:PROCESSING → READY
func (mf *MediaFile) MarkReady(optimizedKey, thumbnailKey string) error {
    if mf.Status != MediaStatusProcessing {
        return fmt.Errorf("only PROCESSING files can be marked ready")
    }
    mf.Status = MediaStatusReady
    mf.UpdatedAt = time.Now()

    mf.recordEvent(&MediaReadyEvent{
        MediaID:       mf.ID,
        OrgID:         mf.OrgID,
        OptimizedKey:  optimizedKey,
        ThumbnailKey:  thumbnailKey,
    })
    return nil
}

// MarkFailed 処理失敗:→ FAILED
func (mf *MediaFile) MarkFailed(errorMsg string) error {
    mf.Status = MediaStatusFailed
    mf.UpdatedAt = time.Now()

    mf.recordEvent(&MediaProcessingFailedEvent{
        MediaID:   mf.ID,
        ErrorMsg:  errorMsg,
    })
    return nil
}

// IsReadyForDelivery 配信可能か
func (mf *MediaFile) IsReadyForDelivery() bool {
    return mf.Status == MediaStatusReady
}

// CanBeAccessedBy アクセス権チェック
func (mf *MediaFile) CanBeAccessedBy(userID string, isMember bool, isEventMember bool) bool {
    switch mf.AccessPolicy {
    case AccessPolicyPrivate:
        return mf.UploaderID == userID
    case AccessPolicyOrgMembers:
        return isMember
    case AccessPolicyEventMembers:
        return isEventMember
    default:
        return false
    }
}

// MediaChunk チャンク式アップロード用
type MediaChunk struct {
    ID          string
    MediaID     string
    ChunkIndex  int    // 0-based
    StorageKey  string // オブジェクトストレージ (Garage / OCI OSS) のキー
    SizeBytes   int64
    UploadedAt  time.Time
}

// NewMediaChunk チャンク作成
func NewMediaChunk(mediaID string, chunkIndex int, storageKey string, size int64) *MediaChunk {
    return &MediaChunk{
        ID:         generateULID(),
        MediaID:    mediaID,
        ChunkIndex: chunkIndex,
        StorageKey: storageKey,
        SizeBytes:  size,
        UploadedAt: time.Now(),
    }
}

// ProcessingJob 非同期処理ジョブ
type ProcessingJobType string

const (
    ProcessingJobTypeHEICConvert  ProcessingJobType = "HEIC_CONVERT"   // libheif で HEIC→JPEG/WebP
    ProcessingJobTypeHLSTranscode ProcessingJobType = "HLS_TRANSCODE"  // FFmpeg で 360p/720p/1080p, 6秒セグメント
    ProcessingJobTypeLivePhotoPair ProcessingJobType = "LIVE_PHOTO_PAIR" // HEIC+MOV を asset_identifier でペアリング
    ProcessingJobTypeThumbnailGen ProcessingJobType = "THUMBNAIL_GEN"
)

type ProcessingJobStatus string

const (
    ProcessingJobStatusPending ProcessingJobStatus = "PENDING"
    ProcessingJobStatusRunning ProcessingJobStatus = "RUNNING"
    ProcessingJobStatusDone    ProcessingJobStatus = "DONE"
    ProcessingJobStatusFailed  ProcessingJobStatus = "FAILED"
)

type ProcessingJob struct {
    ID        string
    MediaID   string
    JobType   ProcessingJobType
    Status    ProcessingJobStatus
    ErrorMsg  *string
    CreatedAt time.Time
    UpdatedAt time.Time
}

// NewProcessingJob ジョブ作成
func NewProcessingJob(mediaID string, jobType ProcessingJobType) *ProcessingJob {
    return &ProcessingJob{
        ID:        generateULID(),
        MediaID:   mediaID,
        JobType:   jobType,
        Status:    ProcessingJobStatusPending,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
}

// MarkDone ジョブ完了
func (pj *ProcessingJob) MarkDone() {
    pj.Status = ProcessingJobStatusDone
    pj.UpdatedAt = time.Now()
}

// MarkFailed ジョブ失敗
func (pj *ProcessingJob) MarkFailed(errMsg string) {
    pj.Status = ProcessingJobStatusFailed
    pj.ErrorMsg = &errMsg
    pj.UpdatedAt = time.Now()
}

// ドメインイベント
type MediaUploadedEvent struct {
    MediaID    string
    OrgID      string
    UploaderID string
    MimeType   string
    FileSize   int64
}

type MediaReadyEvent struct {
    MediaID      string
    OrgID        string
    OptimizedKey string
    ThumbnailKey string
}

type MediaProcessingFailedEvent struct {
    MediaID  string
    ErrorMsg string
}

// DomainEvents インターフェース
func (mf *MediaFile) DomainEvents() []interface{} {
    events := mf.domainEvents
    mf.domainEvents = []interface{}{}
    return events
}

func (mf *MediaFile) recordEvent(event interface{}) {
    mf.domainEvents = append(mf.domainEvents, event)
}

4. ユースケース層(アプリケーション)

4.1 ユースケース一覧

ユースケース 説明 アクター 主成功シナリオ
UploadMediaSingle 単一ファイルアップロード Org Member ファイル検証、オブジェクトストレージ保存、MediaFile 作成、処理ジョブ登録
InitChunkedUpload チャンク式アップロード初期化 Org Member MediaFile 作成(UPLOADING)、チャンク情報返却
UploadChunk チャンク 1 個アップロード Org Member オブジェクトストレージ へのアップロード、MediaChunk 記録
MergeChunks チャンク統合&処理開始 Org Member 全チャンク確認、統合、processing job 登録
DeliverMedia メディア配信(presigned URL) Org Member アクセス権チェック、presigned URL 生成
DeleteMedia メディア削除 Uploader or Org Admin 権限チェック、オブジェクトストレージ 削除、DB レコード削除
ProcessMedia 非同期処理(Queue worker) Job Queue HEIC→JPEG/WebP 変換、HLS トランスコード、サムネイル生成、オブジェクトストレージ 保存
TranscodeVideoToHLS 動画を HLS マルチビットレートに変換 Job Queue FFmpeg で 360p/720p/1080p、6秒セグメント、マスタープレイリスト生成
ConvertHEICToOptimized HEIC を JPEG/WebP に変換 Job Queue libheif で HEIC デコード → JPEG/WebP エンコード
HandleLivePhotoPair Live Photo の HEIC+MOV をペアリング Job Queue asset_identifier により同一 Live Photo として保存

4.2 ユースケース詳細 (UploadMediaSingle - main use case)

Actor: 組織メンバー

Pre-conditions: - ユーザーが org に属する - ファイルサイズ、MIME タイプ有効

Main Flow: 1. UploadMediaSingleRequest を受け取る(file body, mime_type, access_policy) 2. ファイルサイズ検証(100MB 以下の画像、500MB 以下の動画) 3. MIME タイプホワイトリスト検証 4. MediaFile.NewMediaFile() でドメインエンティティ構築 5. オブジェクトストレージ (Garage / OCI OSS) の raw/ プレフィックスへ保存 6. MediaRepository.Save() で DB 保存 7. MIME に応じて ProcessingJob を作成: - 画像 (HEIC): HEIC_CONVERT, THUMBNAIL_GEN - 動画 (MP4): HLS_TRANSCODE, THUMBNAIL_GEN - Live Photo: LIVE_PHOTO_PAIR 8. QueuePort 経由でジョブ投入(Beta: Redis+BullMQ/asynq、Prod: OCI Queue) 9. MediaUploaded イベント発行 → Album Service 10. UploadMediaSingleResponse(media_id, status=UPLOADING)を返却

Post-conditions: - MediaFile が DB に UPLOADING 状態で保存 - ファイルが オブジェクトストレージ raw/ フォルダに保存 - Processing Jobs が QueuePort 経由でキューに登録 - MediaUploaded イベント が QueuePort 経由で発行(Beta: Redis Pub/Sub or BullMQ、Prod: OCI Queue)

Errors: - ファイルサイズ超過:ErrFileTooLarge - MIME タイプ不支持:ErrUnsupportedMimeType - オブジェクトストレージ 保存失敗:ErrStorageFailed(リトライ対象) - 権限不足:ErrUnauthorized

4.3 入出力DTO (Go struct pseudocode)

package application

// UploadMediaSingleRequest
type UploadMediaSingleRequest struct {
    FileBody     []byte // multipart form body
    OriginalName string
    MimeType     string
    FileSize     int64
    OrgID        string
    UploaderID   string
    AccessPolicy string // PRIVATE, ORG_MEMBERS, EVENT_MEMBERS
}

// UploadMediaSingleResponse
type UploadMediaSingleResponse struct {
    MediaID string    `json:"media_id"`
    Status  string    `json:"status"`
    OrgID   string    `json:"org_id"`
    SavedAt time.Time `json:"saved_at"`
}

// InitChunkedUploadRequest
type InitChunkedUploadRequest struct {
    OriginalName string `json:"original_name"`
    MimeType     string `json:"mime_type"`
    FileSizeBytes int64 `json:"file_size_bytes"`
    ChunkSizeBytes int64 `json:"chunk_size_bytes"` // 推奨 5MB
    OrgID        string `json:"org_id"`
    UploaderID   string `json:"uploader_id"`
    AccessPolicy string `json:"access_policy"`
}

// InitChunkedUploadResponse
type InitChunkedUploadResponse struct {
    MediaID         string `json:"media_id"`
    ChunkCount      int    `json:"chunk_count"`
    ChunkSizeBytes  int64  `json:"chunk_size_bytes"`
    ExpiresAt       time.Time `json:"expires_at"`
}

// UploadChunkRequest
type UploadChunkRequest struct {
    MediaID    string `json:"media_id"`
    ChunkIndex int    `json:"chunk_index"`
    ChunkBody  []byte // multipart form body
    ChunkSize  int64  `json:"chunk_size"`
}

// UploadChunkResponse
type UploadChunkResponse struct {
    MediaID    string `json:"media_id"`
    ChunkIndex int    `json:"chunk_index"`
    UploadedAt time.Time `json:"uploaded_at"`
}

// MergeChunksRequest
type MergeChunksRequest struct {
    MediaID string `json:"media_id"`
    OrgID   string `json:"org_id"`
}

// MergeChunksResponse
type MergeChunksResponse struct {
    MediaID string    `json:"media_id"`
    Status  string    `json:"status"` // PROCESSING
    JobID   string    `json:"job_id"`
}

// DeliverMediaRequest
type DeliverMediaRequest struct {
    MediaID      string `json:"media_id"`
    DeliveryType string `json:"delivery_type"` // original, optimized, thumb
    ViewerID     string `json:"viewer_id"`
}

// DeliverMediaResponse
type DeliverMediaResponse struct {
    PresignedURL string    `json:"presigned_url"`
    ExpiresAt    time.Time `json:"expires_at"`
}

// DeleteMediaRequest
type DeleteMediaRequest struct {
    MediaID  string `json:"media_id"`
    OrgID    string `json:"org_id"`
    RequestBy string `json:"request_by"` // user_id
}

// DeleteMediaResponse
type DeleteMediaResponse struct {
    MediaID   string    `json:"media_id"`
    DeletedAt time.Time `json:"deleted_at"`
}

4.4 リポジトリインターフェース(ポート)

package application

import "context"

// MediaFileRepository MediaFile 永続化のポート
type MediaFileRepository interface {
    // Save メディアファイル保存
    Save(ctx context.Context, media *domain.MediaFile) error

    // FindByID ID で検索
    FindByID(ctx context.Context, mediaID string) (*domain.MediaFile, error)

    // ListByOrg 組織内メディア一覧
    ListByOrg(ctx context.Context, orgID string, limit, offset int) ([]*domain.MediaFile, int64, error)

    // Delete メディア削除
    Delete(ctx context.Context, mediaID string) error

    // Update メディア更新(ステータス変更など)
    Update(ctx context.Context, media *domain.MediaFile) error
}

// MediaChunkRepository チャンク永続化のポート
type MediaChunkRepository interface {
    // Save チャンク記録
    Save(ctx context.Context, chunk *domain.MediaChunk) error

    // ListByMedia メディアの全チャンク
    ListByMedia(ctx context.Context, mediaID string) ([]*domain.MediaChunk, error)

    // DeleteExpired 24時間以上経過したチャンク削除
    DeleteExpired(ctx context.Context) error
}

// ProcessingJobRepository ジョブ管理のポート
type ProcessingJobRepository interface {
    // Save ジョブ保存
    Save(ctx context.Context, job *domain.ProcessingJob) error

    // FindByID ジョブ検索
    FindByID(ctx context.Context, jobID string) (*domain.ProcessingJob, error)

    // ListPending ペンディングジョブ取得
    ListPending(ctx context.Context, limit int) ([]*domain.ProcessingJob, error)

    // Update ジョブ更新
    Update(ctx context.Context, job *domain.ProcessingJob) error
}

4.5 外部サービスインターフェース(ポート)

package application

// StoragePort オブジェクトストレージ操作のポート(Beta: Garage / Prod: OCI Object Storage)
type StoragePort interface {
    // Upload ファイルをオブジェクトストレージに保存
    Upload(ctx context.Context, key string, data []byte) error

    // UploadStream ストリーム形式でアップロード(大容量対応)
    UploadStream(ctx context.Context, key string, reader io.Reader, size int64) error

    // Download オブジェクトストレージ からダウンロード
    Download(ctx context.Context, key string) ([]byte, error)

    // GeneratePresignedURL 署名付き URL 生成(配信用)
    GeneratePresignedURL(ctx context.Context, key string, expirySeconds int64) (string, error)

    // Delete オブジェクトストレージ からファイル削除
    Delete(ctx context.Context, key string) error

    // ListObjects キー プレフィックス配下のオブジェクト列挙
    ListObjects(ctx context.Context, prefix string) ([]string, error)
}

// MediaTranscoderPort メディア変換(画像・動画・Live Photo)のポート
// 実装:
//   - LibheifImageAdapter: HEIC → JPEG/WebP(libheif)
//   - FFmpegHLSAdapter: MP4 → HLS 360p/720p/1080p(FFmpeg, 6秒セグメント)
type MediaTranscoderPort interface {
    // ConvertHEICToOptimized HEIC を JPEG/WebP に変換(libheif)
    ConvertHEICToOptimized(ctx context.Context, heicBytes []byte, target ImageFormat) ([]byte, error)

    // GenerateThumbnail サムネイル生成(≤1280px)
    GenerateThumbnail(ctx context.Context, imageBytes []byte, maxWidth int) ([]byte, error)

    // OptimizeImage Web 用に最適化(圧縮、リサイズ)
    OptimizeImage(ctx context.Context, imageBytes []byte) ([]byte, error)

    // TranscodeVideoToHLS MP4 を HLS マルチビットレートに変換(360p/720p/1080p, 6s seg)
    TranscodeVideoToHLS(ctx context.Context, in TranscodeInput) (TranscodeOutput, error)

    // PairLivePhoto HEIC + MOV を asset_identifier でペアリング
    PairLivePhoto(ctx context.Context, heicKey, movKey, assetIdentifier string) error
}

// QueuePort 非同期ジョブキューのポート
// 実装:
//   - Beta: RedisBullMQAdapter / AsynqAdapter
//   - Prod: OCIQueueAdapter
type QueuePort interface {
    // Enqueue ジョブをキューに入れる
    Enqueue(ctx context.Context, queue string, payload []byte, opts ...EnqueueOption) error
}

// EventPublisher ドメインイベント発行のポート(QueuePort 上に実装)
type EventPublisher interface {
    // Publish ドメインイベント発行
    Publish(ctx context.Context, event interface{}) error
}

5. インターフェースアダプタ層

5.1 コントローラ / ハンドラ

ハンドラ HTTP Method Path 入力 出力 責務
UploadMediaSingleHandler POST /api/media multipart form UploadMediaSingleResponse ファイル検証、ユースケース呼び出し
InitChunkedUploadHandler POST /api/media/chunked/init InitChunkedUploadRequest InitChunkedUploadResponse チャンク数計算、MediaFile 初期化
UploadChunkHandler PUT /api/media/chunks/{id}/{index} multipart form UploadChunkResponse チャンク検証、オブジェクトストレージ アップロード
MergeChunksHandler POST /api/media/chunks/{id}/merge MergeChunksRequest MergeChunksResponse 全チャンク確認、統合ジョブ投入
DeliverMediaHandler GET /api/media/{id}/download Query params Redirect or PresignedURL 権限チェック、URL 生成
DeleteMediaHandler DELETE /api/media/{id} - StatusResponse 権限チェック、オブジェクトストレージ + DB 削除

5.2 プレゼンター / レスポンスマッパー

package adapter

// MediaPresenter ドメインモデル → HTTP レスポンス
type MediaPresenter struct{}

// PresentUploadResponse アップロード レスポンス
func (p *MediaPresenter) PresentUploadResponse(media *domain.MediaFile) *UploadMediaSingleResponse {
    return &UploadMediaSingleResponse{
        MediaID: media.ID,
        Status:  string(media.Status),
        OrgID:   media.OrgID,
        SavedAt: media.CreatedAt,
    }
}

// PresentDeliverResponse 配信レスポンス
func (p *MediaPresenter) PresentDeliverResponse(presignedURL string, expiryTime time.Time) *DeliverMediaResponse {
    return &DeliverMediaResponse{
        PresignedURL: presignedURL,
        ExpiresAt:    expiryTime,
    }
}

// PresentErrorResponse エラー応答
func PresentErrorResponse(err error) (statusCode int, body map[string]string) {
    if errors.Is(err, ErrFileTooLarge) {
        return http.StatusRequestEntityTooLarge, map[string]string{"error": "file_too_large"}
    }
    if errors.Is(err, ErrUnsupportedMimeType) {
        return http.StatusBadRequest, map[string]string{"error": "unsupported_mime_type"}
    }
    if errors.Is(err, ErrUnauthorized) {
        return http.StatusForbidden, map[string]string{"error": "unauthorized"}
    }
    return http.StatusInternalServerError, map[string]string{"error": "internal_error"}
}

5.3 リポジトリ実装(アダプタ)

リポジトリ実装 対象 技術 キャッシング
MySQLMediaFileRepository MediaFile database/sql + sqlc メタデータ → Redis (TTL 5min)
MySQLMediaChunkRepository MediaChunk database/sql + sqlc なし(一時的)
MySQLProcessingJobRepository ProcessingJob database/sql + sqlc ペンディング → Redis set

5.4 外部サービスアダプタ

アダプタ ポート 環境 実装 / ライブラリ エラーハンドリング
GarageStorageAdapter StoragePort Beta Garage(S3互換OSS)+ AWS SDK for Go (S3API) 5xx 時 3回リトライ
OCIObjectStorageAdapter StoragePort Prod OCI Go SDK objectstorage 5xx 時 3回リトライ
LibheifImageAdapter MediaTranscoderPort 両方 libheif (C lib) via cgo 処理失敗 → job FAILED
FFmpegHLSAdapter MediaTranscoderPort 両方 FFmpeg CLI(360p/720p/1080p, 6s seg) 処理失敗 → job FAILED
RedisBullMQAdapter QueuePort Beta github.com/hibiken/asynq または BullMQ リトライ 3回、max backoff 1hour
OCIQueueAdapter QueuePort Prod OCI Queue Service SDK リトライ 3回、DLQ 送信

削除済み: S3AdapterMinioAdapterSQSAdapterSNSAdapterImageMagickProcessor はポリシーにより使用しない。

5.5 マッパー

package adapter

// MediaFileMapper DB ↔ ドメイン
type MediaFileMapper struct{}

// ToEntity SQL 結果 → ドメイン MediaFile
func (m *MediaFileMapper) ToEntity(row *MediaFileRow) (*domain.MediaFile, error) {
    storageKey := domain.NewStorageKey(row.OrgID, row.ID, "raw")

    return &domain.MediaFile{
        ID:               row.ID,
        OrgID:            row.OrgID,
        UploaderID:       row.UploaderID,
        OriginalFilename: row.OriginalFilename,
        MimeType:         domain.MimeType(row.MimeType),
        FileSizeBytes:    row.FileSizeBytes,
        StorageKey:       storageKey,
        Status:           domain.MediaStatus(row.Status),
        AccessPolicy:     domain.AccessPolicy(row.AccessPolicy),
        CreatedAt:        row.CreatedAt,
        UpdatedAt:        row.UpdatedAt,
    }, nil
}

// ToPersistence ドメイン MediaFile → DB 挿入
func (m *MediaFileMapper) ToPersistence(media *domain.MediaFile) *MediaFileRow {
    return &MediaFileRow{
        ID:               media.ID,
        OrgID:            media.OrgID,
        UploaderID:       media.UploaderID,
        OriginalFilename: media.OriginalFilename,
        MimeType:         string(media.MimeType),
        FileSizeBytes:    media.FileSizeBytes,
        StorageKey:       media.StorageKey.String(),
        Status:           string(media.Status),
        AccessPolicy:     string(media.AccessPolicy),
        CreatedAt:        media.CreatedAt,
        UpdatedAt:        media.UpdatedAt,
    }
}

6. フレームワーク&ドライバ層(インフラストラクチャ)

6.1 Webフレームワーク

  • フレームワーク: Gin v1.10
  • ポート: 8004
  • ベースパス: /api/media
  • ミドルウェア: CORS, Auth Token 検証, Request ID, Multipart Size Limit (510MB), Logging

6.2 データベース (MySQL 8.x / MariaDB 互換)

互換性方針: Beta は MySQL 8.x(MariaDB 10.6+ と互換)、Prod は OCI MySQL HeatWave。以下の SQL は PostgreSQL 固有記法(TIMESTAMP WITH TIME ZONEJSONBINETgen_random_uuid()PARTITION OF)を 用いず、MySQL/MariaDB 共通の DATETIMEJSONVARCHAR(45)(IPv4/IPv6 最大長)、UUID() または アプリ層生成 ULID を採用する。CHECK 制約は MariaDB 10.2+ / MySQL 8 で有効。

-- media_files テーブル
CREATE TABLE IF NOT EXISTS media_files (
    id TEXT PRIMARY KEY,
    org_id TEXT NOT NULL,
    uploader_id TEXT NOT NULL,
    original_filename TEXT NOT NULL,
    mime_type VARCHAR(50) NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/heic', 'video/mp4')),
    file_size_bytes BIGINT NOT NULL CHECK (file_size_bytes > 0),
    storage_key TEXT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'UPLOADING' CHECK (status IN ('UPLOADING', 'PROCESSING', 'READY', 'FAILED')),
    access_policy VARCHAR(20) NOT NULL CHECK (access_policy IN ('PRIVATE', 'ORG_MEMBERS', 'EVENT_MEMBERS')),
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
    FOREIGN KEY (uploader_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_media_files_org_id ON media_files(org_id, created_at DESC);
CREATE INDEX idx_media_files_uploader_id ON media_files(uploader_id, created_at DESC);
CREATE INDEX idx_media_files_status ON media_files(status);

-- media_chunks テーブル(チャンク式アップロード用)
CREATE TABLE IF NOT EXISTS media_chunks (
    id TEXT PRIMARY KEY,
    media_id TEXT NOT NULL,
    chunk_index INT NOT NULL,
    storage_key TEXT NOT NULL,
    size_bytes BIGINT NOT NULL,
    uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT media_chunks_media_index_unique UNIQUE (media_id, chunk_index),
    FOREIGN KEY (media_id) REFERENCES media_files(id) ON DELETE CASCADE
);

CREATE INDEX idx_media_chunks_media_id ON media_chunks(media_id);
CREATE INDEX idx_media_chunks_uploaded_at ON media_chunks(uploaded_at);

-- processing_jobs テーブル
CREATE TABLE IF NOT EXISTS processing_jobs (
    id TEXT PRIMARY KEY,
    media_id TEXT NOT NULL,
    job_type VARCHAR(30) NOT NULL CHECK (job_type IN ('HEIC_CONVERT', 'HLS_TRANSCODE', 'LIVE_PHOTO_PAIR', 'THUMBNAIL_GEN')),
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')),
    error_msg TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (media_id) REFERENCES media_files(id) ON DELETE CASCADE
);

CREATE INDEX idx_processing_jobs_media_id ON processing_jobs(media_id);
CREATE INDEX idx_processing_jobs_status ON processing_jobs(status);
CREATE INDEX idx_processing_jobs_created_at ON processing_jobs(created_at);

6.3 メッセージブローカー

Beta / Prod ともに QueuePort 経由。環境別に以下のアダプタを差し替える:

環境 アダプタ 実装
Beta RedisBullMQAdapter Redis + BullMQ / asynq(self-hosted)
Prod OCIQueueAdapter OCI Queue Service
  • Job Queue 設定:
  • Queue: default (high priority: HEIC_CONVERT / HLS_TRANSCODE), thumbnail (lower priority)
  • Retry: 3回、Max backoff 1 hour、DLQ 送信
  • TTL: 24時間
  • Event Publishing (QueuePort 上のトピック/チャネル):
  • Message: MediaUploaded, MediaReady, MediaProcessingFailed
  • Consumers: Album Service, Timeline Service, Notification Service
  • AWS SQS / SNS は使用しない(ポリシー)。

6.4 外部ライブラリ&SDK

ライブラリ 用途 バージョン
github.com/gin-gonic/gin Web フレームワーク v1.10
github.com/go-sql-driver/mysql MySQL / MariaDB ドライバ v1.8
github.com/redis/go-redis/v9 Redis クライアント v9.3
github.com/hibiken/asynq Job Queue (Beta, Redis-backed) v10.5
github.com/oracle/oci-go-sdk/v65 OCI Queue / Object Storage SDK (Prod) v65
github.com/aws/aws-sdk-go-v2/service/s3 S3 API クライアント (Garage 用) v1.x
github.com/strukturag/libheif (cgo bind) HEIC → JPEG/WebP 変換 (libheif) v1.17+
FFmpeg CLI(システムバイナリ) HLS トランスコード (360p/720p/1080p, 6s) v6+
github.com/oklog/ulid/v2 ULID 生成 v2.1

6.5 依存性注入 (uber-go/fx code example)

package infra

import (
    "go.uber.org/fx"
    "github.com/gin-gonic/gin"
    "github.com/lib/pq"
    "database/sql"
)

// Module Storage Service fx Module
func Module() fx.Option {
    return fx.Module("storage-service",
        // インフラプロバイダ
        fx.Provide(
            provideMySQLDB,
            provideRedisClient,
            provideObjectStorageClient,   // Beta: Garage (S3-compatible) / Prod: OCI OSS
            provideQueueClient,           // Beta: Redis / Prod: OCI Queue
            provideGinEngine,
        ),
        // 外部サービスプロバイダ(ポート → アダプタ)
        fx.Provide(
            // StoragePort
            func(cfg *config.StorageConfig, s3c ObjectStorageClient) application.StoragePort {
                switch cfg.Provider {
                case "garage":
                    return adapter.NewGarageStorageAdapter(s3c, cfg.Bucket)
                case "oci-oss":
                    return adapter.NewOCIObjectStorageAdapter(s3c, cfg.Bucket)
                default:
                    panic("unsupported storage.provider: " + cfg.Provider)
                }
            },
            // MediaTranscoderPort
            func() application.MediaTranscoderPort {
                return adapter.NewCompositeTranscoder(
                    adapter.NewLibheifImageAdapter(),
                    adapter.NewFFmpegHLSAdapter(),
                )
            },
            // QueuePort
            func(cfg *config.QueueConfig, redis *redis.Client, ociQ OCIQueueClient) application.QueuePort {
                switch cfg.Provider {
                case "redis-bullmq":
                    return adapter.NewRedisBullMQAdapter(redis)
                case "oci-queue":
                    return adapter.NewOCIQueueAdapter(ociQ)
                default:
                    panic("unsupported queue.provider: " + cfg.Provider)
                }
            },
        ),
        // リポジトリプロバイダ
        fx.Provide(
            func(db *sql.DB) adapter.MediaFileRepository {
                return adapter.NewMySQLMediaFileRepository(db)
            },
            func(db *sql.DB) adapter.MediaChunkRepository {
                return adapter.NewMySQLMediaChunkRepository(db)
            },
            func(db *sql.DB) adapter.ProcessingJobRepository {
                return adapter.NewMySQLProcessingJobRepository(db)
            },
        ),
        // ユースケース
        fx.Provide(
            func(
                mediaRepo adapter.MediaFileRepository,
                storage application.StoragePort,
                jobQueue application.QueuePort,
                emitter application.EventPublisher,
            ) application.UploadMediaSingleUseCase {
                return application.NewUploadMediaSingleUseCase(mediaRepo, storage, jobQueue, emitter)
            },
            func(
                mediaRepo adapter.MediaFileRepository,
                chunkRepo adapter.MediaChunkRepository,
                jobRepo adapter.ProcessingJobRepository,
                storage application.StoragePort,
                transcoder application.MediaTranscoderPort,
                jobQueue application.QueuePort,
            ) application.ProcessMediaUseCase {
                return application.NewProcessMediaUseCase(mediaRepo, chunkRepo, jobRepo, storage, transcoder, jobQueue)
            },
            // その他のユースケース...
        ),
        // ハンドラ登録
        fx.Invoke(registerHandlers),
    )
}

func provideMySQLDB(cfg *config.DatabaseConfig) (*sql.DB, error) {
    // MySQL 8.x / MariaDB 互換 DSN
    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%d)/%s?parseTime=true&tls=%s",
        cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database, cfg.TLSMode,
    )
    return sql.Open("mysql", dsn)
}

func provideRedisClient(cfg *config.RedisConfig) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr: cfg.Address,
    })
}

// provideObjectStorageClient は Beta (Garage) では AWS S3 互換クライアント、
// Prod (OCI Object Storage) では OCI Go SDK クライアントを返す
func provideObjectStorageClient(cfg *config.StorageConfig) ObjectStorageClient {
    switch cfg.Provider {
    case "garage":
        // Garage は S3 互換 API、AWS SDK for Go v2 を流用
        return adapter.NewS3CompatibleClient(cfg.Endpoint, cfg.AccessKey, cfg.SecretKey, cfg.Region)
    case "oci-oss":
        return adapter.NewOCIObjectStorageClient(cfg.OCITenancy, cfg.OCINamespace, cfg.OCIRegion)
    default:
        panic("unsupported storage.provider: " + cfg.Provider)
    }
}

// provideQueueClient は Beta で Redis、Prod で OCI Queue クライアントを返す
func provideQueueClient(cfg *config.QueueConfig, redis *redis.Client) QueueClient {
    switch cfg.Provider {
    case "redis-bullmq":
        return adapter.NewRedisQueueClient(redis)
    case "oci-queue":
        return adapter.NewOCIQueueClient(cfg.OCIQueueOCID, cfg.OCIRegion)
    default:
        panic("unsupported queue.provider: " + cfg.Provider)
    }
}

func provideGinEngine() *gin.Engine {
    engine := gin.New()
    engine.Use(gin.Recovery())
    // Multipart size limit: 510MB (image 100MB + video 500MB + overhead)
    engine.MaxMultipartMemory = 510 << 20
    return engine
}

func registerHandlers(
    engine *gin.Engine,
    uploadSingleUC application.UploadMediaSingleUseCase,
    deliverUC application.DeliverMediaUseCase,
    deleteUC application.DeleteMediaUseCase,
) {
    api := engine.Group("/api/media")
    {
        api.POST("", func(c *gin.Context) {
            handler := adapter.NewUploadMediaSingleHandler(uploadSingleUC)
            handler.Handle(c)
        })
        api.GET("/:id/download", func(c *gin.Context) {
            handler := adapter.NewDeliverMediaHandler(deliverUC)
            handler.Handle(c)
        })
        api.DELETE("/:id", func(c *gin.Context) {
            handler := adapter.NewDeleteMediaHandler(deleteUC)
            handler.Handle(c)
        })
    }
}

7. ディレクトリ構成

recerdo-storage/
├── cmd/
│   ├── main.go                 # HTTP サーバー起動
│   └── worker/
│       └── main.go             # Queue 処理ワーカー (Beta: asynq/BullMQ, Prod: OCI Queue)
├── internal/
│   ├── domain/
│   │   ├── media_file.go       # MediaFile エンティティ
│   │   ├── media_chunk.go      # MediaChunk エンティティ
│   │   ├── processing_job.go   # ProcessingJob エンティティ
│   │   ├── value_objects.go    # MediaStatus, MimeType, AccessPolicy など
│   │   └── events.go           # ドメインイベント
│   ├── application/
│   │   ├── dto.go
│   │   ├── ports.go
│   │   ├── upload_media_single.go
│   │   ├── init_chunked_upload.go
│   │   ├── upload_chunk.go
│   │   ├── merge_chunks.go
│   │   ├── deliver_media.go
│   │   ├── delete_media.go
│   │   └── process_media.go    # 非同期処理ユースケース
│   ├── adapter/
│   │   ├── http/
│   │   │   ├── upload_handler.go
│   │   │   ├── chunked_init_handler.go
│   │   │   ├── upload_chunk_handler.go
│   │   │   ├── merge_chunks_handler.go
│   │   │   ├── deliver_handler.go
│   │   │   └── delete_handler.go
│   │   ├── persistence/
│   │   │   ├── MySQL_media_file_repo.go
│   │   │   ├── MySQL_media_chunk_repo.go
│   │   │   └── MySQL_processing_job_repo.go
│   │   ├── external/
│   │   │   ├── s3_storage.go
│   │   │   ├── imagemagick_processor.go
│   │   │   ├── redis_bullmq_adapter.go    # Beta
│   │   │   └── oci_queue_adapter.go       # Prod
│   │   ├── worker/
│   │   │   └── processing_task_handler.go  # Queue worker task handler
│   │   ├── presenter.go
│   │   └── mapper.go
│   └── infra/
│       ├── config.go
│       ├── database.go
│       ├── redis.go
│       ├── s3.go
│       ├── fx_module.go
│       └── migrations/
│           └── 001_create_media_tables.sql
├── test/
│   ├── integration/
│   │   ├── upload_single_test.go
│   │   └── chunked_upload_test.go
│   └── unit/
│       ├── domain/
│       │   ├── media_file_test.go
│       │   └── media_chunk_test.go
│       └── application/
│           └── upload_media_usecase_test.go
├── go.mod
├── go.sum
├── Dockerfile
└── README.md

8. 依存性ルールと境界

8.1 許可される依存関係

レイヤー 依存可能な対象
フレームワーク&ドライバ層 すべて下位 HTTP Handler → UseCase → Domain
インターフェースアダプタ層 ユースケース以下 Storage Adapter → UseCase → Domain
ユースケース層 ドメイン層のみ UploadMediaSingleUseCase → domain.MediaFile
ドメイン層 なし 自己完結

8.2 境界の横断

  • ポート経由:UseCase → StorageService, ImageProcessingService
  • DTO 経由:HTTP Request → DTO → UseCase → Domain
  • イベント駆動:ドメインイベント → EventPublisher → QueuePort(Beta: Redis, Prod: OCI Queue)

8.3 ルールの強制

  • コンパイル時:Go 型チェック
  • 実行時:linter (golangci-lint)
  • レビュー時:コードレビュー

9. テスト戦略

9.1 テストピラミッド

テストタイプ 割合 対象 ツール
ユニットテスト 70% ドメイン、ユースケース(Mock) testing + testify
統合テスト 20% Handler + UseCase + Repo + Storage Mock testcontainers-go, Garage コンテナ
エンドツーエンド 10% 全フロー(Garage / Redis Queue 含む) docker-compose, API テスト

9.2 テスト例 (Go test code)

package domain_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "storage-svc/internal/domain"
)

func TestNewMediaFile_Success(t *testing.T) {
    // Arrange
    mimeType := domain.MimeTypeJPEG

    // Act
    media, err := domain.NewMediaFile(
        "org-123",
        "user-456",
        "photo.jpg",
        mimeType,
        50_000_000, // 50MB
        domain.AccessPolicyPrivate,
    )

    // Assert
    assert.NoError(t, err)
    assert.Equal(t, domain.MediaStatusUploading, media.Status)
    assert.Equal(t, domain.AccessPolicyPrivate, media.AccessPolicy)
    assert.Len(t, media.DomainEvents(), 1)
}

func TestNewMediaFile_FileTooLarge(t *testing.T) {
    // 画像が 100MB を超える場合
    _, err := domain.NewMediaFile(
        "org-123",
        "user-456",
        "huge.jpg",
        domain.MimeTypeJPEG,
        200_000_000, // 200MB
        domain.AccessPolicyPrivate,
    )

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "file size exceeds limit")
}

func TestMediaFile_CanBeAccessedBy(t *testing.T) {
    media, _ := domain.NewMediaFile(
        "org-123",
        "user-456",
        "photo.jpg",
        domain.MimeTypeJPEG,
        10_000_000,
        domain.AccessPolicyOrgMembers,
    )

    tests := []struct {
        name          string
        userID        string
        isMember      bool
        isEventMember bool
        expected      bool
    }{
        {
            name:     "Uploader can access PRIVATE",
            userID:   "user-456",
            isMember: false,
            expected: true,
        },
        {
            name:     "Other member can access ORG_MEMBERS",
            userID:   "user-789",
            isMember: true,
            expected: true,
        },
        {
            name:     "Non-member cannot access ORG_MEMBERS",
            userID:   "user-999",
            isMember: false,
            expected: false,
        },
    }

    // Policy を ORG_MEMBERS に更新して再テスト
    media.AccessPolicy = domain.AccessPolicyOrgMembers

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := media.CanBeAccessedBy(tt.userID, tt.isMember, tt.isEventMember)
            assert.Equal(t, tt.expected, result)
        })
    }
}

// 統合テスト
package application_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "storage-svc/internal/application"
    "storage-svc/internal/adapter"
)

func TestUploadMediaSingleUseCase_Integration(t *testing.T) {
    ctx := context.Background()

    // Setup: MySQL (MariaDB 互換), Garage (ローカル), Redis Queue
    db, cleanup := setupTestDB(t)
    defer cleanup()

    s3Mock := &mockStorageService{}
    jobQueueMock := &mockJobQueue{}
    mediaRepo := adapter.NewMySQLMediaFileRepository(db)
    emitterMock := &mockEventEmitter{}

    uc := application.NewUploadMediaSingleUseCase(mediaRepo, s3Mock, jobQueueMock, emitterMock)

    // Act
    resp, err := uc.Execute(ctx, &application.UploadMediaSingleRequest{
        FileBody:     []byte("fake jpeg data"),
        OriginalName: "photo.jpg",
        MimeType:     "image/jpeg",
        FileSize:     1_000_000,
        OrgID:        "org-123",
        UploaderID:   "user-456",
        AccessPolicy: "PRIVATE",
    })

    // Assert
    assert.NoError(t, err)
    assert.NotEmpty(t, resp.MediaID)
    assert.Equal(t, "UPLOADING", resp.Status)

    // オブジェクトストレージにアップロードされたか確認
    assert.Equal(t, 1, s3Mock.uploadCount)

    // Queue にジョブが投入されたか確認
    assert.Equal(t, 2, jobQueueMock.enqueueCount) // HEIC_CONVERT + THUMBNAIL_GEN (不要な場合は 1)

    // イベントが発行されたか確認
    assert.Equal(t, 1, emitterMock.publishCount)
}

// Mock 実装
type mockStorageService struct {
    uploadCount int
}

func (m *mockStorageService) Upload(ctx context.Context, key string, data []byte) error {
    m.uploadCount++
    return nil
}

func (m *mockStorageService) UploadStream(ctx context.Context, key string, reader io.Reader, size int64) error {
    return nil
}

func (m *mockStorageService) Download(ctx context.Context, key string) ([]byte, error) {
    return []byte{}, nil
}

func (m *mockStorageService) GeneratePresignedURL(ctx context.Context, key string, expirySeconds int64) (string, error) {
    return "https://fake-presigned.url", nil
}

func (m *mockStorageService) Delete(ctx context.Context, key string) error {
    return nil
}

func (m *mockStorageService) ListObjects(ctx context.Context, prefix string) ([]string, error) {
    return []string{}, nil
}

type mockJobQueue struct {
    enqueueCount int
}

func (m *mockQueue) Enqueue(ctx context.Context, queue string, payload []byte, opts ...application.EnqueueOption) error {
    m.enqueueCount++
    return nil
}

10. エラーハンドリング

10.1 ドメインエラー

package domain

var (
    ErrFileTooLarge          = errors.New("file size exceeds limit")
    ErrUnsupportedMimeType   = errors.New("unsupported mime type")
    ErrInvalidAccessPolicy   = errors.New("invalid access policy")
    ErrInvalidStatus         = errors.New("invalid status transition")
    ErrAlreadyProcessing     = errors.New("already processing")
)

10.2 アプリケーションエラー

package application

var (
    ErrMediaNotFound         = errors.New("media not found")
    ErrUnauthorizedUpload    = errors.New("not authorized to upload")
    ErrUnauthorizedDelete    = errors.New("not authorized to delete")
    ErrStorageFailed         = errors.New("storage operation failed")
    ErrProcessingFailed      = errors.New("processing failed")
    ErrInvalidChunk          = errors.New("invalid chunk")
    ErrChunkMissing          = errors.New("chunk missing")
)

10.3 エラー変換 (HTTP mapping table)

エラー HTTP ステータス レスポンス
ErrFileTooLarge 413 Payload Too Large {"error": "file_too_large"}
ErrUnsupportedMimeType 400 Bad Request {"error": "unsupported_mime_type"}
ErrUnauthorizedUpload 403 Forbidden {"error": "unauthorized"}
ErrMediaNotFound 404 Not Found {"error": "not_found"}
ErrStorageFailed 500 Internal Server Error {"error": "storage_failed"}

11. 横断的関心事

11.1 ロギング

  • ライブラリ: go.uber.org/zap
  • レベル: DEBUG, INFO, WARN, ERROR
  • ログ対象: ファイルアップロード、処理開始/完了、オブジェクトストレージ 操作、権限チェック、エラー
  • フォーマット: JSON

11.2 認証・認可

  • 認証: JWT トークン(Authorization ヘッダ)
  • 認可: Permission Service 呼び出しで権限チェック(削除時)
  • ポリシー: アップロード時は org 内のみ、削除は uploader か org admin のみ

11.3 バリデーション

  • 入力: Handler でファイルサイズ、MIME タイプ検証
  • ドメイン: MediaFile.NewMediaFile() で詳細バリデーション
  • ビジネス: ユースケース層で DB 照合(重複チェック)

11.4 キャッシング

  • : Redis
  • キー: media:{mediaID}:metadata, media:{orgID}:list
  • TTL: メタデータ 5分、リスト 2分
  • 無効化: ファイル更新・削除時に明示削除

12. マイグレーション計画

12.1 現状

  • モノリシック内のメディア管理(オブジェクトストレージ 保存のみ)
  • 形式変換、サムネイル生成なし(クライアント側実装)
  • アクセス制御が不十分

12.2 目標状態

  • 独立した Storage Service
  • 完全な処理パイプライン(HEIC→JPEG/WebP 変換、HLS トランスコード、Live Photo ペアリング、サムネイル生成)
  • 細粒度なアクセス制御(PRIVATE/ORG_MEMBERS/EVENT_MEMBERS)
  • チャンク式アップロード対応、高スケーラビリティ

12.3 マイグレーション手順

フェーズ 実施内容 期間 依存関係
1. インフラ準備 MySQL(MariaDB互換) テーブル作成、Garage/OCI OSS バケット構成、Queue setup 1週間 なし
2. コア実装 ドメイン層、ユースケース、リポジトリ 2週間 フェーズ1
3. HTTP インターフェース Handler、Presenter、マッピング 1週間 フェーズ2
4. 処理パイプライン HEIC→JPEG/WebP、HLS トランスコード、サムネイル生成、Queue worker 1週間 フェーズ3
5. テスト 統合・E2E テスト、パフォーマンステスト 1週間 フェーズ4
6. デプロイ・データマイグレーション 本番へのロールアウト、既存メディア移行 1週間 フェーズ5

13. 未決事項と決定事項

項目 現在の決定 状態 備考
HEIC 自動変換 必須(ユーザー選択不可) 決定済み Web 互換性のため libheif で JPEG/WebP に変換
Live Photo asset_identifier でペアリング 決定済み iOS の HEIC+MOV を同一 Live Photo として保存
HLS トランスコード 360p/720p/1080p, 6秒セグ 決定済み FFmpeg でマスタープレイリスト生成
ハイライトビデオ ユーザー指定のみ 決定済み ML による自動生成や推薦は行わない
サムネイルサイズ max 1280px 決定済み モバイル・デスクトップ両対応
チャンク有効期限 24時間 決定済み 十分な時間を提供しつつ DB クリーンアップ
Presigned URL 有効期限 1時間 決定済み セキュリティと UX のバランス
ファイル削除戦略 物理削除 決定済み 監査証跡は別途ログで担保
CDN キャッシング Beta: self-hosted CDN / Prod: OCI CDN 決定済み CloudFront は利用しない。presigned URL で cache key 分離
動画トランスコーディング 実装 決定済み HLSやMPEG-DASHとしてプログレッシブ再生できるようにする

14. 参考資料

  • Clean Architecture: Robert C. Martin, "Clean Architecture"
  • Garage (OSS S3互換 Object Storage): https://garagehq.deuxfleurs.fr/
  • OCI Object Storage: https://docs.oracle.com/iaas/Content/Object/home.htm
  • FFmpeg HLS Segmentation: https://ffmpeg.org/ffmpeg-formats.html#hls-2
  • libheif (HEIC デコード): https://github.com/strukturag/libheif
  • MariaDB / MySQL Docs: https://mariadb.com/kb/en/documentation/ / https://dev.mysql.com/doc/
  • OCI Queue Service: https://docs.oracle.com/iaas/Content/queue/home.htm
  • asynq Job Queue (Beta): https://github.com/hibiken/asynq
  • BullMQ (Beta): https://docs.bullmq.io/
  • Go Multipart Upload: https://golang.org/pkg/mime/multipart/
  • Gin Framework: https://github.com/gin-gonic/gin

最終更新: 2026-04-19 ポリシー適用