依 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)。
7.5 KiB
7.5 KiB
Storage — 儲存層介面
詳見 ADR-004。本文件定義
Storeinterface 與實作細節。
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 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(雛形)
// 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:
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 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(冷資料下降費率)。