依 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)。
240 lines
7.5 KiB
Markdown
240 lines
7.5 KiB
Markdown
# 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(冷資料下降費率)。
|