diff --git a/visionA-backend/.env.example b/visionA-backend/.env.example
index 2fb7c3f..ab837cc 100644
--- a/visionA-backend/.env.example
+++ b/visionA-backend/.env.example
@@ -156,6 +156,14 @@ VISIONA_STORAGE_LOCALFS_BASE_URL=http://localhost:3721/storage
# ⚠️ 生產環境必改(openssl rand -hex 32 產生 64 字元 hex)
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 上傳限制
diff --git a/visionA-backend/assets/preset-models/kl520-fcos-detection.nef b/visionA-backend/assets/preset-models/kl520-fcos-detection.nef
new file mode 100644
index 0000000..8d9138a
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl520-fcos-detection.nef differ
diff --git a/visionA-backend/assets/preset-models/kl520-ssd-face-detection.nef b/visionA-backend/assets/preset-models/kl520-ssd-face-detection.nef
new file mode 100644
index 0000000..cbcfab8
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl520-ssd-face-detection.nef differ
diff --git a/visionA-backend/assets/preset-models/kl520-tiny-yolov3.nef b/visionA-backend/assets/preset-models/kl520-tiny-yolov3.nef
new file mode 100644
index 0000000..adefdaa
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl520-tiny-yolov3.nef differ
diff --git a/visionA-backend/assets/preset-models/kl520-yolov5-detection.nef b/visionA-backend/assets/preset-models/kl520-yolov5-detection.nef
new file mode 100644
index 0000000..3ebaf01
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl520-yolov5-detection.nef differ
diff --git a/visionA-backend/assets/preset-models/kl720-fcos-detection.nef b/visionA-backend/assets/preset-models/kl720-fcos-detection.nef
new file mode 100644
index 0000000..e540bff
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl720-fcos-detection.nef differ
diff --git a/visionA-backend/assets/preset-models/kl720-resnet18-classification.nef b/visionA-backend/assets/preset-models/kl720-resnet18-classification.nef
new file mode 100644
index 0000000..af703e7
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl720-resnet18-classification.nef differ
diff --git a/visionA-backend/assets/preset-models/kl720-yolov5-detection.nef b/visionA-backend/assets/preset-models/kl720-yolov5-detection.nef
new file mode 100644
index 0000000..64a0bec
Binary files /dev/null and b/visionA-backend/assets/preset-models/kl720-yolov5-detection.nef differ
diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go
index fd67737..bccd1a6 100644
--- a/visionA-backend/cmd/api-server/main.go
+++ b/visionA-backend/cmd/api-server/main.go
@@ -420,6 +420,8 @@ func main() {
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序)
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2:exchange 自建 device + session token
Storage: storageStore,
+ PresetModelsDir: cfg.Storage.PresetModelsDir, // B8:7 個系統預設模型 .nef 目錄
+ PresetBaseURL: cfg.Storage.PresetBaseURL, // B8:preset download_url 前綴
Converter: converterClient,
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
FileAccessIssuer: fileAccessIssuer, // Phase 0.9(nil 時 /api/models/:id/download 回 501)
diff --git a/visionA-backend/docker/Dockerfile.api-server b/visionA-backend/docker/Dockerfile.api-server
index d01a122..af0703a 100644
--- a/visionA-backend/docker/Dockerfile.api-server
+++ b/visionA-backend/docker/Dockerfile.api-server
@@ -51,6 +51,10 @@ RUN mkdir -p /app/data/storage && chown -R visiona:visiona /app
# 複製 binary
COPY --from=builder --chown=visiona:visiona /out/api-server /app/api-server
+# B8:7 個系統預設模型 .nef 打包進 image(GET /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
USER visiona:visiona
@@ -62,6 +66,7 @@ EXPOSE 3721
ENV VISIONA_HOST=0.0.0.0 \
VISIONA_API_PORT=3721 \
VISIONA_STORAGE_LOCALFS_ROOT=/app/data/storage \
+ VISIONA_PRESET_MODELS_DIR=/app/assets/preset-models \
VISIONA_LOG_LEVEL=info
# Container 層級 healthcheck — docker / compose 會用。
diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go
index cf04f9a..16e758e 100644
--- a/visionA-backend/internal/api/api.go
+++ b/visionA-backend/internal/api/api.go
@@ -98,6 +98,16 @@ type Deps struct {
Storage storage.Store
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:3721(api-server origin);download handler 組
+ // `{PresetBaseURL}/preset-models/{id}.nef` 回給前端導航下載。為空時 preset download 回 501。
+ PresetBaseURL string
+
// Conversion 是 Phase 0.8 轉檔功能的 Service interface(5 個 endpoint 共用)。
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
// (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
registerStorageRoutes(r, deps)
+ // /preset-models/* 不走 AuthMiddleware、不簽 token(B8 系統預設模型公用、本不需授權)。
+ // 刻意用獨立 path 前綴而非掛在 /storage/* 底下:gin(httprouter) 不允許同層級兩個
+ // wildcard(/storage/*filepath 與 /storage/preset/*filepath 會 panic)。
+ registerPresetModelRoutes(r, deps)
+
// /api/pairing/exchange 刻意不走 AuthMiddleware:
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token,
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go
index b413755..cd62dff 100644
--- a/visionA-backend/internal/api/models.go
+++ b/visionA-backend/internal/api/models.go
@@ -99,7 +99,8 @@ func toModelResponse(m *model.Model) ModelResponse {
func modelsListHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.ModelRepo == nil {
- WriteSuccess(c, http.StatusOK, []ModelResponse{})
+ // B8:即使無 user repo(最小骨架),7 個系統預設模型仍對所有人可見。
+ WriteSuccess(c, http.StatusOK, presetResponses())
return
}
// 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)
return
}
- out := make([]ModelResponse, 0, len(models))
+ // B8:list = 7 個系統預設模型(公用、所有人可見、放最前面)+ 該 user 的 model(DB 查)。
+ presets := presetResponses()
+ out := make([]ModelResponse, 0, len(presets)+len(models))
+ out = append(out, presets...)
for _, m := range models {
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。
func modelsGetHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -140,6 +154,11 @@ func modelsGetHandler(deps Deps) gin.HandlerFunc {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
return
}
+ // B8:preset 是公用模型(無 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)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
@@ -483,6 +502,32 @@ type ModelDownloadResponse struct {
// - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態)
func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
+ return
+ }
+
+ // B8:preset 是公用模型,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 不簽 token:Token / ExpiresAt 留空(前端只需 DownloadURL 即可下載)。
+ })
+ return
+ }
+
if deps.ModelRepo == nil {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
return
@@ -492,12 +537,6 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)")
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)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
diff --git a/visionA-backend/internal/api/models_preset_test.go b/visionA-backend/internal/api/models_preset_test.go
new file mode 100644
index 0000000..391c5c3
--- /dev/null
+++ b/visionA-backend/internal/api/models_preset_test.go
@@ -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 刻意留 nil:preset 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_PresetReturnsVisionAURL:preset 回 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 不簽 token:token 欄位應為空(omitempty 後可能不存在或為空字串)。
+ assert.Empty(t, data["token"])
+}
+
+// TestModelsDownload_PresetWorksWithoutFAAConfig:preset download 完全不依賴 FAA 配置。
+// fixture 的 FileAccessIssuer 為 nil(未配置 FAA),preset 仍應成功。
+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_PresetNotConfiguredReturns501:preset 目錄 / 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_ConvertedStillUsesFAA:converted 類仍走 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_UploadedStillReturns501:uploaded 類(無 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 不在 DB,delete 應回 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 不在 DB,ModelRepo.Get 回 ErrNotFound → 404(合理錯誤,不 panic)。
+ assert.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String())
+ assert.Contains(t, w.Body.String(), ErrCodeNotFound)
+}
+
+// ==========================================================================
+// preset 靜態下載 handler(GET /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-2:Content-Disposition 經 mime.FormatMediaType 產生(安全檔名不會被 corrupt)。
+ // 安全字元檔名 mime 會輸出 unquoted;special 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())
+}
diff --git a/visionA-backend/internal/api/models_test.go b/visionA-backend/internal/api/models_test.go
index 5824c15..77f7cec 100644
--- a/visionA-backend/internal/api/models_test.go
+++ b/visionA-backend/internal/api/models_test.go
@@ -285,8 +285,17 @@ func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
- require.Len(t, arr, 1)
- first := arr[0].(map[string]any)
+ // B8:list = 7 preset + 1 user model = 8 筆。找出 user model 那筆(不假設位置)。
+ 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"]
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))
arr, ok := sb.Data.([]any)
require.True(t, ok)
- assert.Len(t, arr, 1, "只應看到自己的 model")
- first := arr[0].(map[string]any)
- assert.Equal(t, "my", first["id"])
+ // B8:list = 7 preset(公用)+ 只有自己的那 1 筆 user model = 8 筆。
+ // 不應看到 other-user 的 model;preset 對所有人可見。
+ 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 應對所有人可見")
}
diff --git a/visionA-backend/internal/api/preset_models.go b/visionA-backend/internal/api/preset_models.go
new file mode 100644
index 0000000..e166572
--- /dev/null
+++ b/visionA-backend/internal/api/preset_models.go
@@ -0,0 +1,106 @@
+// preset_models.go — GET /preset-models/* 公用靜態下載(B8 系統預設模型)。
+//
+// 設計(使用者拍板「簡單版」):
+// - 7 個 preset 的 .nef 打包進 image(PresetModelsDir = ./assets/preset-models)。
+// - 此 handler 直接從該目錄串流檔案,**不簽 token、不走 AuthMiddleware**——preset 是
+// 公用資料、本來就不需授權(對比 /storage/* 用 HMAC 簽章控管 user 私有檔案)。
+// - download handler(models.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-1):PresetModelsDir 或 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 injection(CRLF / 引號破壞 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
+}
diff --git a/visionA-backend/internal/config/config.go b/visionA-backend/internal/config/config.go
index 0fb9d4a..303fa4b 100644
--- a/visionA-backend/internal/config/config.go
+++ b/visionA-backend/internal/config/config.go
@@ -201,6 +201,16 @@ type StorageConfig struct {
Backend string // VISIONA_STORAGE_BACKEND,預設 "localfs"
RootDir string // VISIONA_STORAGE_LOCALFS_ROOT,預設 "./data/storage"
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 直接讀檔串流(不簽 token,preset 公用)。
+ 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 針對模型資源的驗證限制(大小等)。
diff --git a/visionA-backend/internal/config/load.go b/visionA-backend/internal/config/load.go
index 1c7e5d3..7718f2e 100644
--- a/visionA-backend/internal/config/load.go
+++ b/visionA-backend/internal/config/load.go
@@ -56,9 +56,11 @@ func Load() *Config {
IdleTTL: getEnvDuration("VISIONA_SESSION_IDLE_TTL", 24*time.Hour),
},
Storage: StorageConfig{
- Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"),
- RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/storage"),
- BaseURL: getEnvString("VISIONA_STORAGE_LOCALFS_BASE_URL", "http://localhost:3721/storage"),
+ Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"),
+ RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/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{
MaxSizeMB: getEnvInt("VISIONA_MODEL_MAX_SIZE_MB", 100),
diff --git a/visionA-backend/internal/model/presets.go b/visionA-backend/internal/model/presets.go
new file mode 100644
index 0000000..33a9b90
--- /dev/null
+++ b/visionA-backend/internal/model/presets.go
@@ -0,0 +1,189 @@
+// presets.go — 7 個系統預設模型(B8)。
+//
+// 設計決策(使用者拍板「簡單版」):
+// - metadata 寫死成 Go 常數,**不進 DB**。理由:preset 是固定公用資料,不該有 owner
+// 隔離、不該能被刪、也無 seed 重複問題(多實例 / 重啟結果一致)。
+// - 對應的 .nef 檔打包進 visionA-backend image(assets/preset-models/{id}.nef),
+// download 走 visionA 自己的 storage handler(簡單版不簽 token,preset 公用、本不需授權)。
+//
+// 資料來源:local-tool/server/data/models.json(7 筆 metadata + nef 檔)。對映到 Model
+// struct 既有欄位;models.json 有但 Model 無對應欄位的 metadata(quantization / license /
+// version / author / taskType / categories / thumbnail)暫不落地(見 B8 回報)。
+//
+// StorageKey 格式 `models/preset/{id}.nef`:對 download handler 而言只是「preset 識別 +
+// 對外 URL 檔名」用途,實際檔案由 PresetAssetsDir(api 層)提供,不經 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 直接對映
+// - InputShape:inputSize{width,height} → []int{1, 3, height, width}(NCHW,對齊
+// conversion 端與 PG INT[] 既有慣例 [1,3,H,W])。preset 皆 RGB 影像模型,batch=1、channel=3。
+// - Classes:labels 直接對映
+// - TargetChip:supportedHardware[0](每筆只支援單一晶片)
+// - FileSize:modelSize
+// - Source = SourcePreset、OwnerUserID = ""(公用,非任何 user)
+// - StorageKey = models/preset/{id}.nef(不設 FAAObjectKey;download 走 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
+}
diff --git a/visionA-backend/internal/model/presets_test.go b/visionA-backend/internal/model/presets_test.go
new file mode 100644
index 0000000..156b8d6
--- /dev/null
+++ b/visionA-backend/internal/model/presets_test.go
@@ -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 應為獨立拷貝")
+}
diff --git a/visionA-frontend/src/app/models/[id]/model-detail-client.test.tsx b/visionA-frontend/src/app/models/[id]/model-detail-client.test.tsx
new file mode 100644
index 0000000..8bed2c1
--- /dev/null
+++ b/visionA-frontend/src/app/models/[id]/model-detail-client.test.tsx
@@ -0,0 +1,165 @@
+/**
+ * ModelDetailClient 下載按鈕測試(B5)
+ *
+ * 覆蓋(對齊 model-card.test.tsx 的覆蓋風格):
+ * - 顯示條件:converted + ready / preset 顯示;uploaded / 非 ready 不顯示(用與卡片相同的 isModelDownloadable)
+ * - 點下載 → 觸發 store.downloadModel(傳整個 model 物件,與卡片一致)
+ * - 詳細頁外層無 :點擊不需 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/navigation(jsdom 無 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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/visionA-frontend/src/app/models/[id]/model-detail-client.tsx b/visionA-frontend/src/app/models/[id]/model-detail-client.tsx
index 433b5f1..aacc3bb 100644
--- a/visionA-frontend/src/app/models/[id]/model-detail-client.tsx
+++ b/visionA-frontend/src/app/models/[id]/model-detail-client.tsx
@@ -8,7 +8,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { ArrowLeft, Trash2 } from "lucide-react";
+import { ArrowLeft, DownloadIcon, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -25,8 +25,9 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
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 { toast } from "sonner";
@@ -56,12 +57,42 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
const isLoading = useModelStore((s) => s.isLoading);
const fetchModel = useModelStore((s) => s.fetchModel);
const deleteModel = useModelStore((s) => s.deleteModel);
+ const downloadModel = useModelStore((s) => s.downloadModel);
+ // 與卡片一致:this model 下載中時顯示 loading;任一下載中時 disable(store 同時只允許一個下載)。
+ const isDownloading = useModelStore((s) => s.downloadingId === id);
+ const isAnyDownloading = useModelStore((s) => s.downloadingId !== null);
const [deleting, setDeleting] = useState(false);
+ const [downloadBusy, setDownloadBusy] = useState(false);
useEffect(() => {
if (id) void fetchModel(id);
}, [id, fetchModel]);
+ // 與列表卡片同樣的可下載判斷(converted + ready);store 既有 isModelDownloadable。
+ const downloadable = selectedModel ? isModelDownloadable(selectedModel) : false;
+
+ async function handleDownload() {
+ // 詳細頁外層無 ,不需 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() {
setDeleting(true);
const ok = await deleteModel(id);
@@ -126,13 +157,37 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
)}
-
-
-