feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)

# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-28 07:38:25 +08:00
parent 3e45532f55
commit 9ab1a11ea4
24 changed files with 1107 additions and 34 deletions

View File

@ -156,6 +156,14 @@ VISIONA_STORAGE_LOCALFS_BASE_URL=http://localhost:3721/storage
# ⚠️ 生產環境必改openssl rand -hex 32 產生 64 字元 hex # ⚠️ 生產環境必改openssl rand -hex 32 產生 64 字元 hex
VISIONA_STORAGE_SIGNING_SECRET=CHANGE_ME_IN_PRODUCTION_use_openssl_rand_hex_32 VISIONA_STORAGE_SIGNING_SECRET=CHANGE_ME_IN_PRODUCTION_use_openssl_rand_hex_32
# ---- 系統預設模型B8 -------------------------------------
# 7 個公用預設模型 .nef 檔目錄(打包進 image
# GET /preset-models/* 直接串流(不簽 token、公用。容器內預設 /app/assets/preset-models。
VISIONA_PRESET_MODELS_DIR=./assets/preset-models
# preset download_url 前綴api-server 對外 origin不含 /storage
# 部署時改成對外可訪問的 base如 https://api.visiona.cloud。
VISIONA_PRESET_BASE_URL=http://localhost:3721
# ============================================================ # ============================================================
# Model 上傳限制 # Model 上傳限制

View File

@ -420,6 +420,8 @@ func main() {
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序) DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序)
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2exchange 自建 device + session token PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2exchange 自建 device + session token
Storage: storageStore, Storage: storageStore,
PresetModelsDir: cfg.Storage.PresetModelsDir, // B87 個系統預設模型 .nef 目錄
PresetBaseURL: cfg.Storage.PresetBaseURL, // B8preset download_url 前綴
Converter: converterClient, Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501 Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
FileAccessIssuer: fileAccessIssuer, // Phase 0.9nil 時 /api/models/:id/download 回 501 FileAccessIssuer: fileAccessIssuer, // Phase 0.9nil 時 /api/models/:id/download 回 501

View File

@ -51,6 +51,10 @@ RUN mkdir -p /app/data/storage && chown -R visiona:visiona /app
# 複製 binary # 複製 binary
COPY --from=builder --chown=visiona:visiona /out/api-server /app/api-server COPY --from=builder --chown=visiona:visiona /out/api-server /app/api-server
# B87 個系統預設模型 .nef 打包進 imageGET /preset-models/* 直接串流,不簽 token
# 對齊 config.StorageConfig.PresetModelsDir 預設 ./assets/preset-models容器內 /app/assets/preset-models
COPY --chown=visiona:visiona assets/preset-models /app/assets/preset-models
# 切到非 root # 切到非 root
USER visiona:visiona USER visiona:visiona
@ -62,6 +66,7 @@ EXPOSE 3721
ENV VISIONA_HOST=0.0.0.0 \ ENV VISIONA_HOST=0.0.0.0 \
VISIONA_API_PORT=3721 \ VISIONA_API_PORT=3721 \
VISIONA_STORAGE_LOCALFS_ROOT=/app/data/storage \ VISIONA_STORAGE_LOCALFS_ROOT=/app/data/storage \
VISIONA_PRESET_MODELS_DIR=/app/assets/preset-models \
VISIONA_LOG_LEVEL=info VISIONA_LOG_LEVEL=info
# Container 層級 healthcheck — docker / compose 會用。 # Container 層級 healthcheck — docker / compose 會用。

View File

@ -98,6 +98,16 @@ type Deps struct {
Storage storage.Store Storage storage.Store
Converter converter.Client Converter converter.Client
// PresetModelsDir 是 7 個系統預設模型 .nef 檔B8的本地目錄打包進 image
// 非空時 NewRouter 註冊 GET /preset-models/*filepath不簽 token、不走 auth
// 直接從此目錄串流檔案。為空時不註冊該路由preset download 會回 501。
PresetModelsDir string
// PresetBaseURL 是 preset download_url 的前綴(對外可直接 GET 的 base
// 例 http://localhost:3721api-server origindownload handler 組
// `{PresetBaseURL}/preset-models/{id}.nef` 回給前端導航下載。為空時 preset download 回 501。
PresetBaseURL string
// Conversion 是 Phase 0.8 轉檔功能的 Service interface5 個 endpoint 共用)。 // Conversion 是 Phase 0.8 轉檔功能的 Service interface5 個 endpoint 共用)。
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED // 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
// main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire對齊 api-conversion.md。 // main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire對齊 api-conversion.md。
@ -213,6 +223,11 @@ func NewRouter(deps Deps) *gin.Engine {
// /storage/* 不走 AuthMiddleware改用 HMAC 簽章)— 對齊 api-spec.md §10 // /storage/* 不走 AuthMiddleware改用 HMAC 簽章)— 對齊 api-spec.md §10
registerStorageRoutes(r, deps) registerStorageRoutes(r, deps)
// /preset-models/* 不走 AuthMiddleware、不簽 tokenB8 系統預設模型公用、本不需授權)。
// 刻意用獨立 path 前綴而非掛在 /storage/* 底下gin(httprouter) 不允許同層級兩個
// wildcard/storage/*filepath 與 /storage/preset/*filepath 會 panic
registerPresetModelRoutes(r, deps)
// /api/pairing/exchange 刻意不走 AuthMiddleware // /api/pairing/exchange 刻意不走 AuthMiddleware
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token // agent 尚未有 session token 時就得用 Pairing Token 換 Session Token
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。 // Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。

View File

@ -99,7 +99,8 @@ func toModelResponse(m *model.Model) ModelResponse {
func modelsListHandler(deps Deps) gin.HandlerFunc { func modelsListHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if deps.ModelRepo == nil { if deps.ModelRepo == nil {
WriteSuccess(c, http.StatusOK, []ModelResponse{}) // B8即使無 user repo最小骨架7 個系統預設模型仍對所有人可見。
WriteSuccess(c, http.StatusOK, presetResponses())
return return
} }
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
@ -120,7 +121,10 @@ func modelsListHandler(deps Deps) gin.HandlerFunc {
WriteDBError(c, deps.Logger, "list models", err) WriteDBError(c, deps.Logger, "list models", err)
return return
} }
out := make([]ModelResponse, 0, len(models)) // B8list = 7 個系統預設模型(公用、所有人可見、放最前面)+ 該 user 的 modelDB 查)。
presets := presetResponses()
out := make([]ModelResponse, 0, len(presets)+len(models))
out = append(out, presets...)
for _, m := range models { for _, m := range models {
out = append(out, toModelResponse(m)) out = append(out, toModelResponse(m))
} }
@ -128,6 +132,16 @@ func modelsListHandler(deps Deps) gin.HandlerFunc {
} }
} }
// presetResponses 把 7 個系統預設模型轉成 API DTO順序固定
func presetResponses() []ModelResponse {
presets := model.PresetModels()
out := make([]ModelResponse, 0, len(presets))
for _, m := range presets {
out = append(out, toModelResponse(m))
}
return out
}
// modelsGetHandler 實作 GET /api/models/:id。 // modelsGetHandler 實作 GET /api/models/:id。
func modelsGetHandler(deps Deps) gin.HandlerFunc { func modelsGetHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
@ -140,6 +154,11 @@ func modelsGetHandler(deps Deps) gin.HandlerFunc {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
return return
} }
// B8preset 是公用模型(無 owner、不在 DB任何已登入 user 都查得到,不做 ownership 檢查。
if pm, ok := model.PresetByID(id); ok {
WriteSuccess(c, http.StatusOK, toModelResponse(pm))
return
}
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c) uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" { if !ok || uc.UserID == "" {
@ -483,6 +502,32 @@ type ModelDownloadResponse struct {
// - 簽 token 失敗MC 不可用)→ 502 INTERNAL_ERROR對外 mask不洩漏 MC 內部狀態) // - 簽 token 失敗MC 不可用)→ 502 INTERNAL_ERROR對外 mask不洩漏 MC 內部狀態)
func modelsDownloadHandler(deps Deps) gin.HandlerFunc { func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
return
}
// B8preset 是公用模型download 走 visionA 自己(簡單版不簽 token
// 必須在 FAA / ModelRepo nil 檢查之前處理——preset 完全不依賴 FAA / DB。
if pm, ok := model.PresetByID(id); ok {
if deps.PresetModelsDir == "" || deps.PresetBaseURL == "" {
// preset 檔案目錄 / base URL 未配置main.go 沒 wire→ 無從組可下載 URL。
WriteNotImplemented(c, "preset model download not configured (set VISIONA_PRESET_MODELS_DIR)")
return
}
// preset StorageKey = models/preset/{id}.nef對外 URL 用檔名 {id}.nef。
downloadURL := strings.TrimRight(deps.PresetBaseURL, "/") + "/preset-models/" + url.PathEscape(id) + ".nef"
logOrDefault(deps.Logger).Info("models: preset download",
"model_id", pm.ID,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
DownloadURL: downloadURL,
// preset 不簽 tokenToken / ExpiresAt 留空(前端只需 DownloadURL 即可下載)。
})
return
}
if deps.ModelRepo == nil { if deps.ModelRepo == nil {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
return return
@ -492,12 +537,6 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)") WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)")
return return
} }
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
return
}
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c) uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" { if !ok || uc.UserID == "" {

View File

@ -0,0 +1,300 @@
package api
import (
"context"
"encoding/json"
"mime"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/model"
)
// newPresetFixture 建一個帶 preset 配置的 models route fixture。
//
// presetDir / presetBaseURL 空時模擬「preset download 未配置」。
// 同時設 FileAccessIssuer=nil 確保 preset 分支完全獨立於 FAA。
func newPresetFixture(t *testing.T, presetDir, presetBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) {
t.Helper()
repo := model.NewInMemoryRepository()
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext(userID, ""))
g := r.Group("/api")
registerModelRoutes(g, Deps{
ModelRepo: repo,
MaxUploadSizeMB: 10,
PresetModelsDir: presetDir,
PresetBaseURL: presetBaseURL,
// FileAccessIssuer 刻意留 nilpreset download 不該依賴 FAA。
})
return r, repo
}
// ==========================================================================
// list — preset 對所有人可見
// ==========================================================================
func TestModelsList_IncludesPresetsForEveryone(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
require.Len(t, arr, 7, "無 user model 時應只回 7 個 preset")
ids := map[string]bool{}
for _, item := range arr {
ids[item.(map[string]any)["id"].(string)] = true
}
for _, id := range []string{
"kl520-yolov5-detection", "kl520-fcos-detection", "kl520-ssd-face-detection",
"kl520-tiny-yolov3", "kl720-yolov5-detection", "kl720-resnet18-classification",
"kl720-fcos-detection",
} {
assert.True(t, ids[id], "preset %s 應在 list 中", id)
}
}
// TestModelsList_PresetVisibleWhenRepoNil 驗證即使無 ModelRepo最小骨架preset 仍可見。
func TestModelsList_PresetVisibleWhenRepoNil(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
registerModelRoutes(g, Deps{MaxUploadSizeMB: 10}) // ModelRepo nil
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
assert.Len(t, arr, 7)
}
// ==========================================================================
// get — preset 查得到(無 ownership 限制)
// ==========================================================================
func TestModelsGet_PresetFound(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "any-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-resnet18-classification", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "kl720-resnet18-classification", data["id"])
assert.Equal(t, "ImageNet Classification ResNet18 (KL720)", data["name"])
assert.Equal(t, "preset", data["source"])
assert.Equal(t, "KL720", data["target_chip"])
assert.Equal(t, "ready", data["status"])
}
// ==========================================================================
// download — 三分支preset / converted / uploaded
// ==========================================================================
// TestModelsDownload_PresetReturnsVisionAURLpreset 回 visionA 自己的 URL非 FAA、非 501
func TestModelsDownload_PresetReturnsVisionAURL(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-yolov5-detection/download", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t,
"http://localhost:3721/preset-models/kl520-yolov5-detection.nef",
data["download_url"])
// preset 不簽 tokentoken 欄位應為空omitempty 後可能不存在或為空字串)。
assert.Empty(t, data["token"])
}
// TestModelsDownload_PresetWorksWithoutFAAConfigpreset download 完全不依賴 FAA 配置。
// fixture 的 FileAccessIssuer 為 nil未配置 FAApreset 仍應成功。
func TestModelsDownload_PresetWorksWithoutFAAConfig(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-fcos-detection/download", nil))
assert.Equal(t, http.StatusOK, w.Code, "preset 不需 FAA 也能下載; body=%s", w.Body.String())
}
// TestModelsDownload_PresetNotConfiguredReturns501preset 目錄 / baseURL 未配置 → 501。
func TestModelsDownload_PresetNotConfiguredReturns501(t *testing.T) {
r, _ := newPresetFixture(t, "", "", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String())
}
// TestModelsDownload_ConvertedStillUsesFAAconverted 類仍走 FAA沒被 preset 分支破壞)。
func TestModelsDownload_ConvertedStillUsesFAA(t *testing.T) {
exp := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
iss := &fakeIssuer{token: "fdt_abc", expiresAt: exp}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-conv", "demo-user", "models/demo-user/job.nef")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-conv/download", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t,
"https://faa.example.com:5081/files/models/demo-user/job.nef?access_token=fdt_abc",
data["download_url"])
assert.Equal(t, 1, iss.calls, "converted 類仍應簽 FAA token")
}
// TestModelsDownload_UploadedStillReturns501uploaded 類(無 FAAObjectKey、非 preset仍 501。
func TestModelsDownload_UploadedStillReturns501(t *testing.T) {
iss := &fakeIssuer{token: "fdt_x"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
now := time.Now().UTC()
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "m-upload",
OwnerUserID: "demo-user",
Name: "uploaded",
StorageKey: "models/demo-user/m-upload.nef",
Source: model.SourceUploaded,
UploadedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code)
assert.Equal(t, 0, iss.calls)
}
// ==========================================================================
// delete — preset 不在 DBdelete 應回 404不炸
// ==========================================================================
func TestModelsDelete_PresetReturns404(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/kl520-fcos-detection", nil))
// preset 不在 DBModelRepo.Get 回 ErrNotFound → 404合理錯誤不 panic
assert.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
// ==========================================================================
// preset 靜態下載 handlerGET /preset-models/*
// ==========================================================================
// newPresetServeFixture 建一個含實際 .nef 檔的 preset serve fixture。
func newPresetServeFixture(t *testing.T) (*gin.Engine, string) {
t.Helper()
dir := t.TempDir()
content := []byte("fake-nef-bytes-kl520-yolov5")
require.NoError(t, os.WriteFile(filepath.Join(dir, "kl520-yolov5-detection.nef"), content, 0o644))
r := gin.New()
r.Use(RequestIDMiddleware())
// 註冊條件對稱Minor-1須同時帶 PresetModelsDir + PresetBaseURL 才註冊路由。
registerPresetModelRoutes(r, Deps{PresetModelsDir: dir, PresetBaseURL: "http://localhost:3721"})
return r, dir
}
func TestPresetServe_OK(t *testing.T) {
r, _ := newPresetServeFixture(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/kl520-yolov5-detection.nef", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.Equal(t, "fake-nef-bytes-kl520-yolov5", w.Body.String())
// Minor-2Content-Disposition 經 mime.FormatMediaType 產生(安全檔名不會被 corrupt
// 安全字元檔名 mime 會輸出 unquotedspecial char 才加引號 escape防 header injection
cd := w.Header().Get("Content-Disposition")
assert.True(t, strings.HasPrefix(cd, "attachment"), "應為 attachment disposition: %q", cd)
assert.Contains(t, cd, "kl520-yolov5-detection.nef")
// 可被標準 parser 正確還原 → 證明 header 結構合法(無破壞)。
mediaType, params, err := mime.ParseMediaType(cd)
require.NoError(t, err, "Content-Disposition 應可被標準 parser 解析: %q", cd)
assert.Equal(t, "attachment", mediaType)
assert.Equal(t, "kl520-yolov5-detection.nef", params["filename"])
}
func TestPresetServe_NotFound(t *testing.T) {
r, _ := newPresetServeFixture(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/does-not-exist.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestPresetServe_RejectsTraversal 驗證 path-traversal 被擋(不會讀到 root 外的檔)。
func TestPresetServe_RejectsTraversal(t *testing.T) {
r, _ := newPresetServeFixture(t)
// gin 會先 normalize URL path故直接打 handler 用的 raw param 較難觸發;
// 這裡驗證 handler 對含 ".." 的 filepath 一律 400防禦深度
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/preset-models/..%2f..%2fetc%2fpasswd", nil)
r.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code, "traversal 不應成功讀檔")
}
// TestPresetServe_NotRegisteredWhenDirEmpty 驗證 PresetModelsDir 空時不註冊路由。
func TestPresetServe_NotRegisteredWhenDirEmpty(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
registerPresetModelRoutes(r, Deps{PresetModelsDir: ""})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code, "路由未註冊 → gin 預設 404")
}
// TestPresetServe_NotRegisteredWhenBaseURLEmpty 驗證 Minor-1 對稱條件:
// PresetModelsDir 有設但 PresetBaseURL 空 → route 也不註冊(與 download handler 501 對稱),
// 避免「download 501 但 serve route 仍活著」的狀態不一致。
func TestPresetServe_NotRegisteredWhenBaseURLEmpty(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
registerPresetModelRoutes(r, Deps{PresetModelsDir: t.TempDir(), PresetBaseURL: ""})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code, "PresetBaseURL 空 → route 不註冊 → 404")
}
// TestModelsDownload_PresetMissingBaseURLReturns501 驗證 download 端對稱條件:
// PresetModelsDir 有設但 PresetBaseURL 空 → download preset 回 501與 route 不註冊對稱)。
func TestModelsDownload_PresetMissingBaseURLReturns501(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String())
}

View File

@ -285,8 +285,17 @@ func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any) arr, ok := sb.Data.([]any)
require.True(t, ok) require.True(t, ok)
require.Len(t, arr, 1) // B8list = 7 preset + 1 user model = 8 筆。找出 user model 那筆(不假設位置)。
first := arr[0].(map[string]any) require.Len(t, arr, 8)
var first map[string]any
for _, item := range arr {
m := item.(map[string]any)
if m["id"] == "mdl-list-meta" {
first = m
break
}
}
require.NotNil(t, first, "user model must be in list; body=%s", w.Body.String())
rawShape, present := first["input_shape"] rawShape, present := first["input_shape"]
require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String()) require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String())
@ -318,7 +327,14 @@ func TestModelsList_FiltersByOwner(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any) arr, ok := sb.Data.([]any)
require.True(t, ok) require.True(t, ok)
assert.Len(t, arr, 1, "只應看到自己的 model") // B8list = 7 preset公用+ 只有自己的那 1 筆 user model = 8 筆。
first := arr[0].(map[string]any) // 不應看到 other-user 的 modelpreset 對所有人可見。
assert.Equal(t, "my", first["id"]) require.Len(t, arr, 8, "7 preset + 1 own model")
ids := map[string]bool{}
for _, item := range arr {
ids[item.(map[string]any)["id"].(string)] = true
}
assert.True(t, ids["my"], "應看到自己的 model")
assert.False(t, ids["other"], "不應看到別人的 model")
assert.True(t, ids["kl520-yolov5-detection"], "preset 應對所有人可見")
} }

View File

@ -0,0 +1,106 @@
// preset_models.go — GET /preset-models/* 公用靜態下載B8 系統預設模型)。
//
// 設計(使用者拍板「簡單版」):
// - 7 個 preset 的 .nef 打包進 imagePresetModelsDir = ./assets/preset-models
// - 此 handler 直接從該目錄串流檔案,**不簽 token、不走 AuthMiddleware**——preset 是
// 公用資料、本來就不需授權(對比 /storage/* 用 HMAC 簽章控管 user 私有檔案)。
// - download handlermodels.go對 preset model 回 `{PresetBaseURL}/preset-models/{id}.nef`
// 前端拿 URL 直接導航下載(前端流程與既有 converted 類一致,只是 URL 來源不同)。
//
// 安全:仍做 path-traversal 防禦(拒絕 ".." / 絕對路徑 / NUL即使是公用檔也不能讓
// 任意路徑逃出 PresetModelsDir 讀到 image 其他檔案。
package api
import (
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// registerPresetModelRoutes 註冊 GET /preset-models/*filepath。
//
// 註冊條件與 download handler 的 501 條件對稱Minor-1PresetModelsDir 或 PresetBaseURL
// 任一為空都視為「preset 未完整配置」→ 不註冊路由、download 也回 501。避免「download 501
// 但 /preset-models/ route 仍活著」的狀態不一致。
func registerPresetModelRoutes(r *gin.Engine, deps Deps) {
if deps.PresetModelsDir == "" || deps.PresetBaseURL == "" {
return
}
r.GET("/preset-models/*filepath", presetModelGetHandler(deps))
}
// presetModelGetHandler 從 PresetModelsDir 串流 .nef 檔(公用、不驗簽)。
func presetModelGetHandler(deps Deps) gin.HandlerFunc {
// 解析成絕對 root供 path-traversal 比對。
absRoot, rootErr := filepath.Abs(deps.PresetModelsDir)
return func(c *gin.Context) {
if rootErr != nil {
logOrDefault(deps.Logger).Error("preset: invalid PresetModelsDir",
"dir", deps.PresetModelsDir, "err", rootErr.Error(),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"preset models dir misconfigured", nil)
return
}
raw := strings.TrimPrefix(c.Param("filepath"), "/")
if raw == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "empty filename", nil)
return
}
// Path-traversal 防禦:拒絕 NUL / ".." segment / 絕對路徑開頭。
if strings.Contains(raw, "\x00") || hasParentDirSegment(raw) ||
strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, string(filepath.Separator)) {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid filename", nil)
return
}
full := filepath.Join(absRoot, raw)
// 雙重保險:確保最終路徑仍在 root 底下。
if full != absRoot && !strings.HasPrefix(full, absRoot+string(filepath.Separator)) {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid filename", nil)
return
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
// 不洩漏底層錯誤細節;不存在 / 是目錄都回 404。
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "preset model not found", nil)
return
}
logOrDefault(deps.Logger).Info("preset: download",
"file", raw, "size", info.Size(), "request_id", RequestIDFrom(c))
// 設 attachment 讓瀏覽器導航時觸發下載(檔名 = preset 檔名)。
// 用 mime.FormatMediaType 正確 quote/escape 檔名Minor-2 防禦性)——現況 raw 已過
// path-traversal 過濾、preset 檔名固定 {id}.nef 安全字元,但避免未來檔名來源放寬時的
// header injectionCRLF / 引號破壞 header 結構)。
disposition := mime.FormatMediaType("attachment", map[string]string{
"filename": filepath.Base(raw),
})
if disposition == "" {
// FormatMediaType 對非法值回空字串(理論上 raw 已過濾不會發生fallback 不帶檔名。
disposition = "attachment"
}
c.Header("Content-Disposition", disposition)
c.File(full)
}
}
// hasParentDirSegment 回報 path 是否含有 ".." segment"/" 與 OS separator 兩種分隔符)。
func hasParentDirSegment(p string) bool {
for _, sep := range []string{"/", string(filepath.Separator)} {
for _, seg := range strings.Split(p, sep) {
if seg == ".." {
return true
}
}
}
return false
}

View File

@ -201,6 +201,16 @@ type StorageConfig struct {
Backend string // VISIONA_STORAGE_BACKEND預設 "localfs" Backend string // VISIONA_STORAGE_BACKEND預設 "localfs"
RootDir string // VISIONA_STORAGE_LOCALFS_ROOT預設 "./data/storage" RootDir string // VISIONA_STORAGE_LOCALFS_ROOT預設 "./data/storage"
BaseURL string // VISIONA_STORAGE_LOCALFS_BASE_URL預設 "http://localhost:3721/storage"(對齊 api-server port BaseURL string // VISIONA_STORAGE_LOCALFS_BASE_URL預設 "http://localhost:3721/storage"(對齊 api-server port
// PresetModelsDir 是 7 個系統預設模型 .nef 檔B8打包進 image 的目錄。
// VISIONA_PRESET_MODELS_DIR預設 "./assets/preset-models"image 內為 /app/assets/preset-models
// 由 GET /preset-models/* handler 直接讀檔串流(不簽 tokenpreset 公用)。
PresetModelsDir string
// PresetBaseURL 是 preset download_url 前綴api-server 對外 origin不含 /storage
// VISIONA_PRESET_BASE_URL預設 "http://localhost:3721"。
// download handler 組 `{PresetBaseURL}/preset-models/{id}.nef` 回給前端導航下載。
PresetBaseURL string
} }
// ModelConfig 針對模型資源的驗證限制(大小等)。 // ModelConfig 針對模型資源的驗證限制(大小等)。

View File

@ -56,9 +56,11 @@ func Load() *Config {
IdleTTL: getEnvDuration("VISIONA_SESSION_IDLE_TTL", 24*time.Hour), IdleTTL: getEnvDuration("VISIONA_SESSION_IDLE_TTL", 24*time.Hour),
}, },
Storage: StorageConfig{ Storage: StorageConfig{
Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"), Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"),
RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/storage"), RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/storage"),
BaseURL: getEnvString("VISIONA_STORAGE_LOCALFS_BASE_URL", "http://localhost:3721/storage"), BaseURL: getEnvString("VISIONA_STORAGE_LOCALFS_BASE_URL", "http://localhost:3721/storage"),
PresetModelsDir: getEnvString("VISIONA_PRESET_MODELS_DIR", "./assets/preset-models"),
PresetBaseURL: getEnvString("VISIONA_PRESET_BASE_URL", "http://localhost:3721"),
}, },
Model: ModelConfig{ Model: ModelConfig{
MaxSizeMB: getEnvInt("VISIONA_MODEL_MAX_SIZE_MB", 100), MaxSizeMB: getEnvInt("VISIONA_MODEL_MAX_SIZE_MB", 100),

View File

@ -0,0 +1,189 @@
// presets.go — 7 個系統預設模型B8
//
// 設計決策(使用者拍板「簡單版」):
// - metadata 寫死成 Go 常數,**不進 DB**。理由preset 是固定公用資料,不該有 owner
// 隔離、不該能被刪、也無 seed 重複問題(多實例 / 重啟結果一致)。
// - 對應的 .nef 檔打包進 visionA-backend imageassets/preset-models/{id}.nef
// download 走 visionA 自己的 storage handler簡單版不簽 tokenpreset 公用、本不需授權)。
//
// 資料來源local-tool/server/data/models.json7 筆 metadata + nef 檔)。對映到 Model
// struct 既有欄位models.json 有但 Model 無對應欄位的 metadataquantization / license /
// version / author / taskType / categories / thumbnail暫不落地見 B8 回報)。
//
// StorageKey 格式 `models/preset/{id}.nef`:對 download handler 而言只是「preset 識別 +
// 對外 URL 檔名」用途,實際檔案由 PresetAssetsDirapi 層)提供,不經 LocalFS storage root。
package model
import "time"
// presetCreatedAt 是 7 個 preset 的固定建立時間(對齊 models.json 的 createdAt
// 寫死成常數讓 List/Get 回應在重啟 / 多實例間穩定(不用 time.Now(),避免每次不同)。
var presetCreatedAt = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
// presetStorageKey 依 preset id 組出對外用的 storage key檔名穩定、URL 穩定)。
func presetStorageKey(id string) string {
return "models/preset/" + id + ".nef"
}
// presetModels 是寫死的 7 個系統預設模型(公用、無 owner、不可刪
//
// 欄位對映models.json → Model
// - name/description/framework 直接對映
// - InputShapeinputSize{width,height} → []int{1, 3, height, width}NCHW對齊
// conversion 端與 PG INT[] 既有慣例 [1,3,H,W]。preset 皆 RGB 影像模型batch=1、channel=3。
// - Classeslabels 直接對映
// - TargetChipsupportedHardware[0](每筆只支援單一晶片)
// - FileSizemodelSize
// - Source = SourcePreset、OwnerUserID = ""(公用,非任何 user
// - StorageKey = models/preset/{id}.nef不設 FAAObjectKeydownload 走 visionA 自己)
var presetModels = []*Model{
{
ID: "kl520-yolov5-detection",
Name: "YOLOv5 Detection (KL520)",
Description: "YOLOv5 object detection model compiled for Kneron KL520. No upsample variant optimized for NPU inference at 640x640 resolution.",
StorageKey: presetStorageKey("kl520-yolov5-detection"),
FileSize: 7200000,
TargetChip: "KL520",
InputShape: []int{1, 3, 640, 640},
Classes: []string{"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl520-fcos-detection",
Name: "FCOS Detection (KL520)",
Description: "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL520. Anchor-free detection at 512x512.",
StorageKey: presetStorageKey("kl520-fcos-detection"),
FileSize: 8900000,
TargetChip: "KL520",
InputShape: []int{1, 3, 512, 512},
Classes: []string{"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl520-ssd-face-detection",
Name: "SSD Face Detection (KL520)",
Description: "SSD-based face detection with landmark localization, compiled for KL520. Lightweight model suitable for face detection and alignment tasks.",
StorageKey: presetStorageKey("kl520-ssd-face-detection"),
FileSize: 1000000,
TargetChip: "KL520",
InputShape: []int{1, 3, 240, 320},
Classes: []string{"face"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl520-tiny-yolov3",
Name: "Tiny YOLOv3 (KL520)",
Description: "Tiny YOLOv3 object detection model compiled for KL520. Compact and fast model for general-purpose multi-object detection on edge devices.",
StorageKey: presetStorageKey("kl520-tiny-yolov3"),
FileSize: 9400000,
TargetChip: "KL520",
InputShape: []int{1, 3, 416, 416},
Classes: []string{"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl720-yolov5-detection",
Name: "YOLOv5 Detection (KL720)",
Description: "YOLOv5 object detection model compiled for Kneron KL720. No upsample variant optimized for KL720 NPU inference at 640x640 resolution with USB 3.0 throughput.",
StorageKey: presetStorageKey("kl720-yolov5-detection"),
FileSize: 10168348,
TargetChip: "KL720",
InputShape: []int{1, 3, 640, 640},
Classes: []string{"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl720-resnet18-classification",
Name: "ImageNet Classification ResNet18 (KL720)",
Description: "ResNet18-based image classification compiled for KL720. Supports 1000 ImageNet categories with fast inference via USB 3.0.",
StorageKey: presetStorageKey("kl720-resnet18-classification"),
FileSize: 12826804,
TargetChip: "KL720",
InputShape: []int{1, 3, 224, 224},
Classes: []string{"airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
{
ID: "kl720-fcos-detection",
Name: "FCOS Detection (KL720)",
Description: "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL720. Anchor-free detection at 512x512.",
StorageKey: presetStorageKey("kl720-fcos-detection"),
FileSize: 13004640,
TargetChip: "KL720",
InputShape: []int{1, 3, 512, 512},
Classes: []string{"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"},
Framework: "NEF",
Source: SourcePreset,
CreatedAt: presetCreatedAt,
UpdatedAt: presetCreatedAt,
UploadedAt: &presetCreatedAt,
},
}
// PresetModels 回傳所有系統預設模型的深拷貝(避免呼叫端意外改到共用常數)。
//
// 回傳順序固定(與宣告順序一致),讓 List 回應穩定、測試可確定性斷言。
func PresetModels() []*Model {
out := make([]*Model, 0, len(presetModels))
for _, m := range presetModels {
out = append(out, clonePreset(m))
}
return out
}
// PresetByID 依 id 取單一 preset 的深拷貝;非 preset id 回 (nil, false)。
func PresetByID(id string) (*Model, bool) {
for _, m := range presetModels {
if m.ID == id {
return clonePreset(m), true
}
}
return nil, false
}
// IsPresetID 判斷 id 是否為系統預設模型 id。
func IsPresetID(id string) bool {
_, ok := PresetByID(id)
return ok
}
// clonePreset 深拷貝一筆 preset含 slice 欄位),避免外部修改污染共用常數。
func clonePreset(m *Model) *Model {
cp := *m
if m.InputShape != nil {
cp.InputShape = append([]int(nil), m.InputShape...)
}
if m.Classes != nil {
cp.Classes = append([]string(nil), m.Classes...)
}
// UploadedAt 指向共用的 presetCreatedAt複製一份新指標避免別名共享。
if m.UploadedAt != nil {
t := *m.UploadedAt
cp.UploadedAt = &t
}
return &cp
}

View File

@ -0,0 +1,76 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPresetModels_Count 驗證恰好 7 個系統預設模型。
func TestPresetModels_Count(t *testing.T) {
got := PresetModels()
require.Len(t, got, 7)
}
// TestPresetModels_AllSourcePresetNoOwner 驗證每筆 preset 都是公用Source=preset、無 owner、有 StorageKey、無 FAAObjectKey
func TestPresetModels_AllSourcePresetNoOwner(t *testing.T) {
for _, m := range PresetModels() {
assert.Equal(t, SourcePreset, m.Source, "preset %s source", m.ID)
assert.Empty(t, m.OwnerUserID, "preset %s should have no owner", m.ID)
assert.Empty(t, m.FAAObjectKey, "preset %s should not have FAAObjectKey", m.ID)
assert.Equal(t, "models/preset/"+m.ID+".nef", m.StorageKey, "preset %s storage key", m.ID)
assert.NotEmpty(t, m.Name, "preset %s name", m.ID)
assert.NotEmpty(t, m.TargetChip, "preset %s target chip", m.ID)
assert.Greater(t, m.FileSize, int64(0), "preset %s file size", m.ID)
}
}
// TestPresetModels_ExpectedIDs 驗證 7 個 id 與 models.json 對齊(順序固定)。
func TestPresetModels_ExpectedIDs(t *testing.T) {
want := []string{
"kl520-yolov5-detection",
"kl520-fcos-detection",
"kl520-ssd-face-detection",
"kl520-tiny-yolov3",
"kl720-yolov5-detection",
"kl720-resnet18-classification",
"kl720-fcos-detection",
}
got := PresetModels()
require.Len(t, got, len(want))
for i, id := range want {
assert.Equal(t, id, got[i].ID, "preset[%d] id", i)
}
}
// TestPresetByID_FoundAndNotFound 驗證查找。
func TestPresetByID_FoundAndNotFound(t *testing.T) {
m, ok := PresetByID("kl720-resnet18-classification")
require.True(t, ok)
assert.Equal(t, "ImageNet Classification ResNet18 (KL720)", m.Name)
assert.Equal(t, "KL720", m.TargetChip)
assert.Equal(t, []int{1, 3, 224, 224}, m.InputShape)
_, ok = PresetByID("not-a-preset")
assert.False(t, ok)
assert.True(t, IsPresetID("kl520-yolov5-detection"))
assert.False(t, IsPresetID("random-uuid"))
}
// TestPresetModels_ReturnsDeepCopy 驗證回傳是深拷貝——改動回傳值不影響後續呼叫(保護共用常數)。
func TestPresetModels_ReturnsDeepCopy(t *testing.T) {
a, ok := PresetByID("kl520-yolov5-detection")
require.True(t, ok)
origLen := len(a.Classes)
a.Name = "MUTATED"
a.Classes = append(a.Classes, "injected")
a.InputShape[0] = 999
b, ok := PresetByID("kl520-yolov5-detection")
require.True(t, ok)
assert.NotEqual(t, "MUTATED", b.Name, "回傳值修改不應污染共用常數")
assert.Len(t, b.Classes, origLen, "Classes slice 應為獨立拷貝")
assert.Equal(t, 1, b.InputShape[0], "InputShape slice 應為獨立拷貝")
}

View File

@ -0,0 +1,165 @@
/**
* ModelDetailClient B5
*
* model-card.test.tsx
* - converted + ready / preset uploaded / ready isModelDownloadable
* - store.downloadModel model
* - <Link> preventDefault / stopPropagation download
* - toast.success toast.error backend code i18n
* - loading this model disabled +
* - downloadingId != null disabled
*
* Mock
* - sonner toast success / error
* - model-store fetchModel / downloadModel vi.spyOn selectedModel _setSelected
* - next/navigationjsdom app router context
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useModelStore, type Model } from "@/stores/model-store";
import { ModelDetailClient } from "./model-detail-client";
vi.mock("sonner", () => {
const success = vi.fn();
const error = vi.fn();
return { toast: Object.assign(vi.fn(), { success, error }) };
});
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
import { toast } from "sonner";
const convertedReady: Model = {
id: "m1",
name: "YOLOv5s",
targetChip: "kl520",
fileSize: 1024,
source: "converted",
status: "ready",
createdAt: "2026-01-01T00:00:00Z",
};
function renderDetail() {
return render(
<LocaleProvider>
<ModelDetailClient id="m1" />
</LocaleProvider>,
);
}
beforeEach(() => {
// fetchModel 在 useEffect 會被呼叫 — stub 成 no-op避免打網路selectedModel 由測試自行注入。
vi.spyOn(useModelStore.getState(), "fetchModel").mockResolvedValue(undefined);
useModelStore.setState({
downloadingId: null,
isLoading: false,
selectedModel: convertedReady,
});
(toast.success as Mock).mockReset();
(toast.error as Mock).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
useModelStore.setState({ downloadingId: null, selectedModel: null });
});
describe("ModelDetailClient 下載按鈕顯示條件", () => {
it("converted + ready → 顯示下載按鈕", () => {
useModelStore.setState({ selectedModel: convertedReady });
renderDetail();
expect(screen.getByTestId("model-detail-download")).toBeInTheDocument();
});
it("uploaded即使 ready→ 不顯示下載按鈕", () => {
useModelStore.setState({ selectedModel: { ...convertedReady, source: "uploaded" } });
renderDetail();
expect(screen.queryByTestId("model-detail-download")).not.toBeInTheDocument();
});
it("converted 但 scanning → 不顯示下載按鈕", () => {
useModelStore.setState({ selectedModel: { ...convertedReady, status: "scanning" } });
renderDetail();
expect(screen.queryByTestId("model-detail-download")).not.toBeInTheDocument();
});
it("preset → 顯示下載按鈕與卡片一致backend B8 內建模型可下載)", () => {
useModelStore.setState({ selectedModel: { ...convertedReady, source: "preset" } });
renderDetail();
expect(screen.getByTestId("model-detail-download")).toBeInTheDocument();
});
});
describe("ModelDetailClient 下載互動", () => {
it("點下載 → 觸發 downloadModel傳整個 model 物件,與卡片一致)", async () => {
const spy = vi
.spyOn(useModelStore.getState(), "downloadModel")
.mockResolvedValue({ ok: true });
renderDetail();
fireEvent.click(screen.getByTestId("model-detail-download"));
await waitFor(() => expect(spy).toHaveBeenCalledWith(convertedReady));
});
it("下載成功 → toast.success", async () => {
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ ok: true });
renderDetail();
fireEvent.click(screen.getByTestId("model-detail-download"));
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
expect(toast.error).not.toHaveBeenCalled();
});
it("下載失敗forbidden→ toast.error描述用對應 i18n", async () => {
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
ok: false,
code: "forbidden",
message: "no",
});
renderDetail();
fireEvent.click(screen.getByTestId("model-detail-download"));
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
const call = (toast.error as Mock).mock.calls[0];
expect(call[1].description).toBe("你沒有權限下載此模型");
});
it("未知 code → toast.error 描述退化成 unknown 文案", async () => {
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
ok: false,
code: "some_unmapped_code",
message: "x",
});
renderDetail();
fireEvent.click(screen.getByTestId("model-detail-download"));
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
const call = (toast.error as Mock).mock.calls[0];
expect(call[1].description).toBe("下載失敗,請稍後再試");
});
it("this model 下載中downloadingId === id→ 按鈕 disabled 且顯示「下載中」", () => {
useModelStore.setState({ downloadingId: "m1", selectedModel: convertedReady });
renderDetail();
const btn = screen.getByTestId("model-detail-download");
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent("下載中");
});
it("其他下載中downloadingId != null 且 != id→ 按鈕 disabled", () => {
useModelStore.setState({ downloadingId: "other-model", selectedModel: convertedReady });
renderDetail();
expect(screen.getByTestId("model-detail-download")).toBeDisabled();
});
});

View File

@ -8,7 +8,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Trash2 } from "lucide-react"; import { ArrowLeft, DownloadIcon, Trash2 } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
@ -25,8 +25,9 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { useModelStore } from "@/stores/model-store"; import { isModelDownloadable, useModelStore } from "@/stores/model-store";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
@ -56,12 +57,42 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
const isLoading = useModelStore((s) => s.isLoading); const isLoading = useModelStore((s) => s.isLoading);
const fetchModel = useModelStore((s) => s.fetchModel); const fetchModel = useModelStore((s) => s.fetchModel);
const deleteModel = useModelStore((s) => s.deleteModel); const deleteModel = useModelStore((s) => s.deleteModel);
const downloadModel = useModelStore((s) => s.downloadModel);
// 與卡片一致this model 下載中時顯示 loading任一下載中時 disablestore 同時只允許一個下載)。
const isDownloading = useModelStore((s) => s.downloadingId === id);
const isAnyDownloading = useModelStore((s) => s.downloadingId !== null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (id) void fetchModel(id); if (id) void fetchModel(id);
}, [id, fetchModel]); }, [id, fetchModel]);
// 與列表卡片同樣的可下載判斷converted + readystore 既有 isModelDownloadable。
const downloadable = selectedModel ? isModelDownloadable(selectedModel) : false;
async function handleDownload() {
// 詳細頁外層無 <Link>,不需 preventDefault / stopPropagation與卡片差異點
if (!selectedModel || downloadBusy || isAnyDownloading) return;
setDownloadBusy(true);
const result = await downloadModel(selectedModel);
setDownloadBusy(false);
if (result.ok) {
toast.success(t("models.download.toast.start"), {
description: t("models.download.toast.hint"),
});
} else {
// 用 backend code 對應 i18n找不到對應 key 時退化到 unknown與卡片一致
const key = `models.download.error.${result.code}`;
const desc = t(key);
toast.error(t("models.download.error.title"), {
description: desc === key ? t("models.download.error.unknown") : desc,
});
}
}
async function handleDelete() { async function handleDelete() {
setDeleting(true); setDeleting(true);
const ok = await deleteModel(id); const ok = await deleteModel(id);
@ -126,13 +157,37 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
)} )}
</div> </div>
</div> </div>
<AlertDialog> <div className="flex items-center gap-2">
<AlertDialogTrigger asChild> {downloadable && (
<Button variant="outline" size="sm" disabled={deleting}> <Button
<Trash2 aria-hidden="true" className="mr-2 size-4" /> type="button"
{t("common.delete")} variant="outline"
size="sm"
onClick={handleDownload}
disabled={isAnyDownloading || downloadBusy}
aria-label={t("models.action.download.aria")}
data-testid="model-detail-download"
>
{isDownloading || downloadBusy ? (
<>
<Spinner size="sm" label={t("models.action.downloading")} />
{t("models.action.downloading")}
</>
) : (
<>
<DownloadIcon aria-hidden="true" className="mr-2 size-4" />
{t("models.action.download")}
</>
)}
</Button> </Button>
</AlertDialogTrigger> )}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle> <AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
@ -145,7 +200,8 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
</div> </div>
<Card> <Card>

View File

@ -86,9 +86,9 @@ describe("ModelCard 下載按鈕顯示條件", () => {
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
}); });
it("preset → 顯示下載按鈕", () => { it("preset → 顯示下載按鈕backend B8 內建模型可下載)", () => {
renderCard({ ...convertedReady, source: "preset" }); renderCard({ ...convertedReady, source: "preset" });
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); expect(screen.getByTestId("model-card-download")).toBeInTheDocument();
}); });
}); });

View File

@ -17,10 +17,21 @@ vi.mock("@/lib/api/model-download", async () => {
}; };
}); });
// mock HTTP clientfetchModels 依賴 api.get保留 ApiError 讓 store 的 NOT_IMPLEMENTED 分支可用。
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
return {
...actual,
api: { ...actual.api, get: vi.fn(), post: vi.fn() },
};
});
import { getModelDownload, triggerNavDownload } from "@/lib/api/model-download"; import { getModelDownload, triggerNavDownload } from "@/lib/api/model-download";
import { api } from "@/lib/api";
const mockGetModelDownload = vi.mocked(getModelDownload); const mockGetModelDownload = vi.mocked(getModelDownload);
const mockTriggerNavDownload = vi.mocked(triggerNavDownload); const mockTriggerNavDownload = vi.mocked(triggerNavDownload);
const mockApiGet = vi.mocked(api.get);
function reset() { function reset() {
useModelStore.setState({ useModelStore.setState({
@ -85,8 +96,69 @@ describe("isModelDownloadablePhase 0.9", () => {
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false); expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
}); });
it("preset → 不可下載", () => { it("preset → 可下載backend B8 內建模型,走 visionA 自己 storage", () => {
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false); expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(true);
});
it("preset 只判 source即使 status 非 ready 仍可下載preset 無 backend status 概念)", () => {
// backend Model struct 無 status 欄位preset 不靠 status 判定。
expect(isModelDownloadable({ source: "preset", status: "scanning" })).toBe(true);
});
});
describe("fetchModels target_chip 大小寫正規化 + 晶片篩選B5 Major-1 回歸)", () => {
beforeEach(() => {
reset();
vi.clearAllMocks();
});
// backend preset 回大寫 target_chippresets.go4 個 KL520 + 3 個 KL720
// 模型庫晶片篩選用前端小寫 enum 嚴格比對 → 若 normalize 不收斂大小寫preset 全被濾掉。
const PRESET_RAW = [
{ id: "p1", name: "YOLOv5 (KL520)", target_chip: "KL520", source: "preset" },
{ id: "p2", name: "FCOS (KL520)", target_chip: "KL520", source: "preset" },
{ id: "p3", name: "SSD Face (KL520)", target_chip: "KL520", source: "preset" },
{ id: "p4", name: "Tiny YOLOv3 (KL520)", target_chip: "KL520", source: "preset" },
{ id: "p5", name: "YOLOv5 (KL720)", target_chip: "KL720", source: "preset" },
{ id: "p6", name: "FCOS (KL720)", target_chip: "KL720", source: "preset" },
{ id: "p7", name: "SSD Face (KL720)", target_chip: "KL720", source: "preset" },
];
// 與 app/models/page.tsx 的篩選 predicate 等價targetChip 嚴格比對)。
function filterByChip(models: ModelSummary[], chip: string) {
return models.filter((m) => m.targetChip === chip);
}
it("fetchModels 後 targetChip 一律小寫(大寫 KL520 → kl520", async () => {
mockApiGet.mockResolvedValue(PRESET_RAW);
await useModelStore.getState().fetchModels();
const models = useModelStore.getState().models;
expect(models).toHaveLength(7);
expect(models.every((m) => m.targetChip === m.targetChip.toLowerCase())).toBe(true);
expect(models.map((m) => m.targetChip)).toContain("kl520");
expect(models.map((m) => m.targetChip)).toContain("kl720");
});
it("按 kl520 篩選 → 看得到 4 個 KL520 preset", async () => {
mockApiGet.mockResolvedValue(PRESET_RAW);
await useModelStore.getState().fetchModels();
expect(filterByChip(useModelStore.getState().models, "kl520")).toHaveLength(4);
});
it("按 kl720 篩選 → 看得到 3 個 KL720 preset", async () => {
mockApiGet.mockResolvedValue(PRESET_RAW);
await useModelStore.getState().fetchModels();
expect(filterByChip(useModelStore.getState().models, "kl720")).toHaveLength(3);
});
it("preset 與 converted 混合 list篩 kl520 同時涵蓋 preset 與 converted", async () => {
mockApiGet.mockResolvedValue([
...PRESET_RAW,
// converted 也可能大寫保護未來其他來源normalize 後應一起被 kl520 篩到。
{ id: "c1", name: "Custom", target_chip: "KL520", source: "converted", status: "ready" },
]);
await useModelStore.getState().fetchModels();
expect(filterByChip(useModelStore.getState().models, "kl520")).toHaveLength(5);
}); });
}); });

View File

@ -88,10 +88,14 @@ function normalizeModelSummary(raw: unknown): ModelSummary {
} }
return undefined; return undefined;
}; };
// target_chip 收斂大小寫:前端 TargetChip 一律小寫kl520…但 backend 來源大小寫不一
// (如 preset 回大寫 "KL520"。normalize 層 toLowerCase 是收斂的正確位置,避免下游
// 嚴格比對(如模型庫晶片篩選 m.targetChip === filter因大小寫不符而漏掉資料。
const rawTargetChip = pick<string>("target_chip", "targetChip");
return { return {
id: String(pick<string>("id") ?? ""), id: String(pick<string>("id") ?? ""),
name: String(pick<string>("name") ?? ""), name: String(pick<string>("name") ?? ""),
targetChip: (pick<string>("target_chip", "targetChip") as TargetChip) ?? "unknown", targetChip: (rawTargetChip?.toLowerCase() as TargetChip) ?? "unknown",
fileSize: Number(pick<number>("file_size", "fileSize") ?? 0), fileSize: Number(pick<number>("file_size", "fileSize") ?? 0),
source: (pick<string>("source") as ModelSource) ?? "uploaded", source: (pick<string>("source") as ModelSource) ?? "uploaded",
status: (pick<string>("status") as ModelStatus) ?? "ready", status: (pick<string>("status") as ModelStatus) ?? "ready",
@ -153,13 +157,21 @@ export type DownloadResult =
| { ok: false; code: string; message: string }; | { ok: false; code: string; message: string };
/** /**
* model Phase 0.9 promote model * model
* *
* `converted` FAA delegated download promote model *
* ADR-017 §10.4 B1 object_key model visionA storage FAA object_key * - `converted` + `ready`promote FAA modelADR-017 §10.4 B1
* backend 501 upload_not_supportedUI 使 501 * FAA delegated download
* - `preset`backend B8 presets.gopreset visionA storage
* StorageKey = models/preset/{id}.nef visionA FAA object_key
* backend Model struct status preset status normalize
* status "ready" backend preset source
*
* `uploaded` model visionA storage FAA object_key
* backend download 501 upload_not_supportedUI 使 501
*/ */
export function isModelDownloadable(model: Pick<ModelSummary, "source" | "status">): boolean { export function isModelDownloadable(model: Pick<ModelSummary, "source" | "status">): boolean {
if (model.source === "preset") return true;
return model.source === "converted" && model.status === "ready"; return model.source === "converted" && model.status === "ready";
} }