# Storage — 儲存層介面 > 詳見 ADR-004。本文件定義 `Store` interface 與實作細節。 --- ## 1. Interface ```go // 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 sidecar(LocalFS 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(雛形) ```go // internal/storage/localfs.go type LocalFSStore struct { root string // 檔案根目錄,例:./data/storage baseURL string // 用來做假 presigned URL,例:http://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: ```go 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 ```go 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) ```go // 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 URL(TTL 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(冷資料下降費率)。