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) { )} - - - - + )} + + + + {t("common.confirm")} @@ -145,7 +200,8 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) { - + + diff --git a/visionA-frontend/src/components/models/model-card.test.tsx b/visionA-frontend/src/components/models/model-card.test.tsx index 8a41215..7e36c75 100644 --- a/visionA-frontend/src/components/models/model-card.test.tsx +++ b/visionA-frontend/src/components/models/model-card.test.tsx @@ -86,9 +86,9 @@ describe("ModelCard 下載按鈕顯示條件", () => { expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); }); - it("preset → 不顯示下載按鈕", () => { + it("preset → 顯示下載按鈕(backend B8 內建模型可下載)", () => { renderCard({ ...convertedReady, source: "preset" }); - expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); + expect(screen.getByTestId("model-card-download")).toBeInTheDocument(); }); }); diff --git a/visionA-frontend/src/stores/model-store.test.ts b/visionA-frontend/src/stores/model-store.test.ts index fafffc0..3c8f301 100644 --- a/visionA-frontend/src/stores/model-store.test.ts +++ b/visionA-frontend/src/stores/model-store.test.ts @@ -17,10 +17,21 @@ vi.mock("@/lib/api/model-download", async () => { }; }); +// mock HTTP client(fetchModels 依賴 api.get);保留 ApiError 讓 store 的 NOT_IMPLEMENTED 分支可用。 +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { + ...actual, + api: { ...actual.api, get: vi.fn(), post: vi.fn() }, + }; +}); + import { getModelDownload, triggerNavDownload } from "@/lib/api/model-download"; +import { api } from "@/lib/api"; const mockGetModelDownload = vi.mocked(getModelDownload); const mockTriggerNavDownload = vi.mocked(triggerNavDownload); +const mockApiGet = vi.mocked(api.get); function reset() { useModelStore.setState({ @@ -85,8 +96,69 @@ describe("isModelDownloadable(Phase 0.9)", () => { expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false); }); - it("preset → 不可下載", () => { - expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false); + it("preset → 可下載(backend B8 內建模型,走 visionA 自己 storage)", () => { + 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_chip(presets.go:4 個 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); }); }); diff --git a/visionA-frontend/src/stores/model-store.ts b/visionA-frontend/src/stores/model-store.ts index e68133f..4ff35fd 100644 --- a/visionA-frontend/src/stores/model-store.ts +++ b/visionA-frontend/src/stores/model-store.ts @@ -88,10 +88,14 @@ function normalizeModelSummary(raw: unknown): ModelSummary { } return undefined; }; + // target_chip 收斂大小寫:前端 TargetChip 一律小寫(kl520…),但 backend 來源大小寫不一 + // (如 preset 回大寫 "KL520")。normalize 層 toLowerCase 是收斂的正確位置,避免下游 + // 嚴格比對(如模型庫晶片篩選 m.targetChip === filter)因大小寫不符而漏掉資料。 + const rawTargetChip = pick("target_chip", "targetChip"); return { id: String(pick("id") ?? ""), name: String(pick("name") ?? ""), - targetChip: (pick("target_chip", "targetChip") as TargetChip) ?? "unknown", + targetChip: (rawTargetChip?.toLowerCase() as TargetChip) ?? "unknown", fileSize: Number(pick("file_size", "fileSize") ?? 0), source: (pick("source") as ModelSource) ?? "uploaded", status: (pick("status") as ModelStatus) ?? "ready", @@ -153,13 +157,21 @@ export type DownloadResult = | { 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, - * backend 會回 501 upload_not_supported。UI 依此條件隱藏下載按鈕、避免使用者點了才吃 501。 + * 可下載來源: + * - `converted` + `ready`:轉檔→promote 進 FAA 的 model(ADR-017 §10.4 B1)。第一階段 + * FAA delegated download 覆蓋此類。 + * - `preset`:backend B8 內建預設模型(presets.go)。preset 在 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_supported。UI 隱藏下載按鈕、避免使用者點了才吃 501。 */ export function isModelDownloadable(model: Pick): boolean { + if (model.source === "preset") return true; return model.source === "converted" && model.status === "ready"; }