jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類
共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git),
讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等
per-branch 筆記。

- 02-prd/        21 個檔(PRD、features、market-analysis 等)
- 03-design/     18 個檔(design-spec、wireframes、flows 等)
- 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等)
- 07-delivery/   3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup)

合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv,
但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
2026-05-04 16:55:55 +08:00

7.5 KiB
Raw Blame History

Storage — 儲存層介面

詳見 ADR-004。本文件定義 Store interface 與實作細節。


1. Interface

// internal/storage/store.go
package storage

import (
    "context"
    "io"
    "time"
)

type Store interface {
    Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error
    Get(ctx context.Context, key string) (io.ReadCloser, *ObjectInfo, error)
    Stat(ctx context.Context, key string) (*ObjectInfo, error)

    // Exists 檢查 object 是否存在。
    // 2026-04-22 Minor-4 新增PRD 明確要求 upload finalize 流程需一個明確的「存在嗎」入口。
    // 語義true = 存在可用false = 不存在(非 error
    // 其他錯誤(權限 / 網路)回傳 (false, err)。
    // 實作上 LocalFS 可用 os.Stat + errors.Is(err, fs.ErrNotExist)S3 可用 HeadObject 404 判斷。
    Exists(ctx context.Context, key string) (bool, error)

    Delete(ctx context.Context, key string) error
    List(ctx context.Context, prefix string) ([]*ObjectInfo, error)
    PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error)
    PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}

type ObjectInfo struct {
    Key          string
    Size         int64
    ContentType  string
    LastModified time.Time
    ETag         string
    Metadata     map[string]string
}

var (
    ErrNotFound     = errors.New("storage: object not found")
    ErrAlreadyExists = errors.New("storage: object already exists")
)

2. Key 命名規範

統一使用 / 分隔的類 S3 路徑風格:

用途 Key 模式 範例
使用者模型 models/{user_id}/{model_id}.{ext} models/demo-user/abc-123.nef
模型 metadata sidecarLocalFS only 同上 + .meta.json models/demo-user/abc-123.nef.meta.json
轉檔源檔 converter/source/{user_id}/{job_id}.{ext} converter/source/demo-user/job-xxx.onnx
轉檔產物 converter/result/{user_id}/{job_id}.nef converter/result/demo-user/job-xxx.nef
推論 snapshot未來 snapshots/{user_id}/{date}/{uuid}.jpg

原則:永遠含 {user_id},便於未來做 IAM policy按 prefix 授權)。


3. LocalFSStore雛形

// internal/storage/localfs.go
type LocalFSStore struct {
    root    string  // 檔案根目錄,例:./data/storage
    baseURL string  // 用來做假 presigned URLhttp://localhost:3001/storage
    signer  *Signer // HMAC sign, see §3.3
}

func NewLocalFS(root, baseURL string) *LocalFSStore { /* ... */ }

func (s *LocalFSStore) Put(ctx, key, r, size, meta) error {
    fullPath := filepath.Join(s.root, key)
    os.MkdirAll(filepath.Dir(fullPath), 0755)
    f, _ := os.Create(fullPath)
    defer f.Close()
    io.Copy(f, r)

    if len(meta) > 0 {
        metaPath := fullPath + ".meta.json"
        json.NewEncoder(os.Create(metaPath)).Encode(map[string]any{
            "metadata": meta,
            "contentType": meta["content-type"],
        })
    }
    return nil
}

func (s *LocalFSStore) PresignedGetURL(ctx, key, ttl) (string, error) {
    expiresAt := time.Now().Add(ttl).Unix()
    sig := s.signer.Sign(fmt.Sprintf("GET\n%s\n%d", key, expiresAt))
    return fmt.Sprintf("%s/%s?expires=%d&signature=%s",
        s.baseURL, url.PathEscape(key), expiresAt, sig), nil
}

3.1 Presigned URL 驗證LocalFS

api-server 對 /storage/*filepath 掛一個 handler

router.GET("/storage/*filepath", func(c *gin.Context) {
    key := strings.TrimPrefix(c.Param("filepath"), "/")
    expires, _ := strconv.ParseInt(c.Query("expires"), 10, 64)
    sig := c.Query("signature")

    if time.Now().Unix() > expires {
        c.AbortWithStatus(403); return
    }
    expected := signer.Sign(fmt.Sprintf("GET\n%s\n%d", key, expires))
    if sig != expected {
        c.AbortWithStatus(403); return
    }

    reader, info, err := storageStore.Get(ctx, key)
    if err != nil { c.AbortWithStatus(404); return }
    defer reader.Close()

    c.Header("Content-Type", info.ContentType)
    c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
    io.Copy(c.Writer, reader)
})

// PUT 版本類似,用於 upload

3.2 安全考量LocalFS

  • Key 使用前必須走 filepath.Clean 並檢查 !strings.HasPrefix(cleaned, root),防止 ../../etc/passwd
  • HMAC 簽名 secret 由 env VISIONA_STORAGE_SIGNING_SECRET 提供若未設定dev 模式用固定字串並印警告

3.3 Signer

type Signer struct {
    secret []byte
}
func (s *Signer) Sign(payload string) string {
    mac := hmac.New(sha256.New, s.secret)
    mac.Write([]byte(payload))
    return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

4. S3Store雛形後期 / Phase 1

// internal/storage/s3.go
type S3Store struct {
    client *s3.Client
    bucket string
}

func NewS3(cfg S3Config) (*S3Store, error) {
    awsCfg, _ := awsconfig.LoadDefaultConfig(ctx,
        awsconfig.WithRegion(cfg.Region),
        awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
    )
    client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
        if cfg.Endpoint != "" {
            o.BaseEndpoint = aws.String(cfg.Endpoint)
            o.UsePathStyle = true  // MinIO 友善
        }
    })
    return &S3Store{client: client, bucket: cfg.Bucket}, nil
}

func (s *S3Store) PresignedGetURL(ctx, key, ttl) (string, error) {
    presigner := s3.NewPresignClient(s.client)
    req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: &s.bucket, Key: &key,
    }, s3.WithPresignExpires(ttl))
    if err != nil { return "", err }
    return req.URL, nil
}

4.1 支援的供應商

供應商 Endpoint 設定 備註
AWS S3 空字串(用預設) UsePathStyle=false
Cloudflare R2 https://xxx.r2.cloudflarestorage.com 零 egress cost
Backblaze B2 https://s3.us-west-000.backblazeb2.com 便宜
MinIO自建 / 本地) http://minio:9000 UsePathStyle=true
Google Cloud Storage https://storage.googleapis.com 需 XML API

5. 使用場景

5.1 模型上傳

1. [瀏覽器] POST /api/models/init {name, size, checksum}
2. [api-server] 建 Model record (status="uploading") + 產 presigned PUT URL
3. [瀏覽器] PUT to presigned URL直接 S3 / LocalFS
4. [瀏覽器] POST /api/models/{id}/finalize
5. [api-server] **Exists(key)** 先確認物件存在PRD 要求),再 Stat 驗 size + checksum → 改 status="ready"

5.2 模型載入到裝置

1. [瀏覽器] POST /api/devices/:id/load-model {model_id}
2. [api-server] 產 presigned GET URLTTL 10 分鐘)
3. [api-server] 透過 tunnel 送 local agent: POST /api/models/download {url, checksum}
4. [local agent] 下載檔案到本機 → 載入 Kneron
5. [local agent] 回 200 OK

為何不透過 tunnel 傳檔案?

  • 模型大(幾百 MB、tunnel 頻寬寶貴
  • local agent 自己網路直連 S3 更快且不占 tunnel

6. 測試

  • storage_test.go 定義一套 testStoreConformance(t, store),對任何實作都跑同一組測試
  • LocalFSStore 測試使用 t.TempDir() 根目錄
  • S3Store 測試用 MinIO in docker可選CI 可 skip

雛形實作LocalFSStore + signer + api-server 的 /storage/* 代理 handler。 未來擴展S3Store、multipart upload、SSE-KMS 加密、bucket lifecycle冷資料下降費率