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

240 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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雛形
```go
// 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
```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 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冷資料下降費率