feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token) 模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。 補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json): - metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複 - 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY) - download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權): download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501 - 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition attachment 用 mime.FormatMediaType、path-traversal 防禦) - list/get 含 preset(對所有人可見) # B5 詳細頁下載按鈕 + preset 可下載 - model-detail-client.tsx 補下載按鈕(行為對齊列表卡片) - isModelDownloadable 加 preset(source==="preset" → true) - normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520 在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫) # 測試 backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠 frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題) # backlog(未做的優化) preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e45532f55
commit
9ab1a11ea4
@ -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 上傳限制
|
||||
|
||||
BIN
visionA-backend/assets/preset-models/kl520-fcos-detection.nef
Normal file
BIN
visionA-backend/assets/preset-models/kl520-fcos-detection.nef
Normal file
Binary file not shown.
Binary file not shown.
BIN
visionA-backend/assets/preset-models/kl520-tiny-yolov3.nef
Normal file
BIN
visionA-backend/assets/preset-models/kl520-tiny-yolov3.nef
Normal file
Binary file not shown.
BIN
visionA-backend/assets/preset-models/kl520-yolov5-detection.nef
Normal file
BIN
visionA-backend/assets/preset-models/kl520-yolov5-detection.nef
Normal file
Binary file not shown.
BIN
visionA-backend/assets/preset-models/kl720-fcos-detection.nef
Normal file
BIN
visionA-backend/assets/preset-models/kl720-fcos-detection.nef
Normal file
Binary file not shown.
Binary file not shown.
BIN
visionA-backend/assets/preset-models/kl720-yolov5-detection.nef
Normal file
BIN
visionA-backend/assets/preset-models/kl720-yolov5-detection.nef
Normal file
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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 會用。
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
300
visionA-backend/internal/api/models_preset_test.go
Normal file
300
visionA-backend/internal/api/models_preset_test.go
Normal file
@ -0,0 +1,300 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/model"
|
||||
)
|
||||
|
||||
// newPresetFixture 建一個帶 preset 配置的 models route fixture。
|
||||
//
|
||||
// presetDir / presetBaseURL 空時模擬「preset download 未配置」。
|
||||
// 同時設 FileAccessIssuer=nil 確保 preset 分支完全獨立於 FAA。
|
||||
func newPresetFixture(t *testing.T, presetDir, presetBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) {
|
||||
t.Helper()
|
||||
repo := model.NewInMemoryRepository()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(RequestIDMiddleware())
|
||||
r.Use(injectStaticUserContext(userID, ""))
|
||||
g := r.Group("/api")
|
||||
registerModelRoutes(g, Deps{
|
||||
ModelRepo: repo,
|
||||
MaxUploadSizeMB: 10,
|
||||
PresetModelsDir: presetDir,
|
||||
PresetBaseURL: presetBaseURL,
|
||||
// FileAccessIssuer 刻意留 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())
|
||||
}
|
||||
@ -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 應對所有人可見")
|
||||
}
|
||||
|
||||
106
visionA-backend/internal/api/preset_models.go
Normal file
106
visionA-backend/internal/api/preset_models.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 針對模型資源的驗證限制(大小等)。
|
||||
|
||||
@ -59,6 +59,8 @@ func Load() *Config {
|
||||
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),
|
||||
|
||||
189
visionA-backend/internal/model/presets.go
Normal file
189
visionA-backend/internal/model/presets.go
Normal file
@ -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
|
||||
}
|
||||
76
visionA-backend/internal/model/presets_test.go
Normal file
76
visionA-backend/internal/model/presets_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPresetModels_Count 驗證恰好 7 個系統預設模型。
|
||||
func TestPresetModels_Count(t *testing.T) {
|
||||
got := PresetModels()
|
||||
require.Len(t, got, 7)
|
||||
}
|
||||
|
||||
// TestPresetModels_AllSourcePresetNoOwner 驗證每筆 preset 都是公用(Source=preset、無 owner、有 StorageKey、無 FAAObjectKey)。
|
||||
func TestPresetModels_AllSourcePresetNoOwner(t *testing.T) {
|
||||
for _, m := range PresetModels() {
|
||||
assert.Equal(t, SourcePreset, m.Source, "preset %s source", m.ID)
|
||||
assert.Empty(t, m.OwnerUserID, "preset %s should have no owner", m.ID)
|
||||
assert.Empty(t, m.FAAObjectKey, "preset %s should not have FAAObjectKey", m.ID)
|
||||
assert.Equal(t, "models/preset/"+m.ID+".nef", m.StorageKey, "preset %s storage key", m.ID)
|
||||
assert.NotEmpty(t, m.Name, "preset %s name", m.ID)
|
||||
assert.NotEmpty(t, m.TargetChip, "preset %s target chip", m.ID)
|
||||
assert.Greater(t, m.FileSize, int64(0), "preset %s file size", m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresetModels_ExpectedIDs 驗證 7 個 id 與 models.json 對齊(順序固定)。
|
||||
func TestPresetModels_ExpectedIDs(t *testing.T) {
|
||||
want := []string{
|
||||
"kl520-yolov5-detection",
|
||||
"kl520-fcos-detection",
|
||||
"kl520-ssd-face-detection",
|
||||
"kl520-tiny-yolov3",
|
||||
"kl720-yolov5-detection",
|
||||
"kl720-resnet18-classification",
|
||||
"kl720-fcos-detection",
|
||||
}
|
||||
got := PresetModels()
|
||||
require.Len(t, got, len(want))
|
||||
for i, id := range want {
|
||||
assert.Equal(t, id, got[i].ID, "preset[%d] id", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresetByID_FoundAndNotFound 驗證查找。
|
||||
func TestPresetByID_FoundAndNotFound(t *testing.T) {
|
||||
m, ok := PresetByID("kl720-resnet18-classification")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "ImageNet Classification ResNet18 (KL720)", m.Name)
|
||||
assert.Equal(t, "KL720", m.TargetChip)
|
||||
assert.Equal(t, []int{1, 3, 224, 224}, m.InputShape)
|
||||
|
||||
_, ok = PresetByID("not-a-preset")
|
||||
assert.False(t, ok)
|
||||
|
||||
assert.True(t, IsPresetID("kl520-yolov5-detection"))
|
||||
assert.False(t, IsPresetID("random-uuid"))
|
||||
}
|
||||
|
||||
// TestPresetModels_ReturnsDeepCopy 驗證回傳是深拷貝——改動回傳值不影響後續呼叫(保護共用常數)。
|
||||
func TestPresetModels_ReturnsDeepCopy(t *testing.T) {
|
||||
a, ok := PresetByID("kl520-yolov5-detection")
|
||||
require.True(t, ok)
|
||||
origLen := len(a.Classes)
|
||||
a.Name = "MUTATED"
|
||||
a.Classes = append(a.Classes, "injected")
|
||||
a.InputShape[0] = 999
|
||||
|
||||
b, ok := PresetByID("kl520-yolov5-detection")
|
||||
require.True(t, ok)
|
||||
assert.NotEqual(t, "MUTATED", b.Name, "回傳值修改不應污染共用常數")
|
||||
assert.Len(t, b.Classes, origLen, "Classes slice 應為獨立拷貝")
|
||||
assert.Equal(t, 1, b.InputShape[0], "InputShape slice 應為獨立拷貝")
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* ModelDetailClient 下載按鈕測試(B5)
|
||||
*
|
||||
* 覆蓋(對齊 model-card.test.tsx 的覆蓋風格):
|
||||
* - 顯示條件:converted + ready / preset 顯示;uploaded / 非 ready 不顯示(用與卡片相同的 isModelDownloadable)
|
||||
* - 點下載 → 觸發 store.downloadModel(傳整個 model 物件,與卡片一致)
|
||||
* - 詳細頁外層無 <Link>:點擊不需 preventDefault / stopPropagation(與卡片差異點,這裡只驗 download 被呼叫)
|
||||
* - 成功 → toast.success;失敗 → toast.error(用 backend code 對應 i18n)
|
||||
* - loading 態:this model 下載中 → 按鈕 disabled + 顯示「下載中」
|
||||
* - 其他下載中(downloadingId != null)→ 按鈕 disabled
|
||||
*
|
||||
* Mock:
|
||||
* - sonner toast → 攔截 success / error
|
||||
* - model-store fetchModel / downloadModel → vi.spyOn 控制;selectedModel 用 _setSelected 注入
|
||||
* - next/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(
|
||||
<LocaleProvider>
|
||||
<ModelDetailClient id="m1" />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// fetchModel 在 useEffect 會被呼叫 — stub 成 no-op,避免打網路;selectedModel 由測試自行注入。
|
||||
vi.spyOn(useModelStore.getState(), "fetchModel").mockResolvedValue(undefined);
|
||||
useModelStore.setState({
|
||||
downloadingId: null,
|
||||
isLoading: false,
|
||||
selectedModel: convertedReady,
|
||||
});
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useModelStore.setState({ downloadingId: null, selectedModel: null });
|
||||
});
|
||||
|
||||
describe("ModelDetailClient 下載按鈕顯示條件", () => {
|
||||
it("converted + ready → 顯示下載按鈕", () => {
|
||||
useModelStore.setState({ selectedModel: convertedReady });
|
||||
renderDetail();
|
||||
expect(screen.getByTestId("model-detail-download")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploaded(即使 ready)→ 不顯示下載按鈕", () => {
|
||||
useModelStore.setState({ selectedModel: { ...convertedReady, source: "uploaded" } });
|
||||
renderDetail();
|
||||
expect(screen.queryByTestId("model-detail-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("converted 但 scanning → 不顯示下載按鈕", () => {
|
||||
useModelStore.setState({ selectedModel: { ...convertedReady, status: "scanning" } });
|
||||
renderDetail();
|
||||
expect(screen.queryByTestId("model-detail-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preset → 顯示下載按鈕(與卡片一致,backend B8 內建模型可下載)", () => {
|
||||
useModelStore.setState({ selectedModel: { ...convertedReady, source: "preset" } });
|
||||
renderDetail();
|
||||
expect(screen.getByTestId("model-detail-download")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelDetailClient 下載互動", () => {
|
||||
it("點下載 → 觸發 downloadModel(傳整個 model 物件,與卡片一致)", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useModelStore.getState(), "downloadModel")
|
||||
.mockResolvedValue({ ok: true });
|
||||
|
||||
renderDetail();
|
||||
fireEvent.click(screen.getByTestId("model-detail-download"));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(convertedReady));
|
||||
});
|
||||
|
||||
it("下載成功 → toast.success", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ ok: true });
|
||||
renderDetail();
|
||||
fireEvent.click(screen.getByTestId("model-detail-download"));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("下載失敗(forbidden)→ toast.error,描述用對應 i18n", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "forbidden",
|
||||
message: "no",
|
||||
});
|
||||
renderDetail();
|
||||
fireEvent.click(screen.getByTestId("model-detail-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("你沒有權限下載此模型");
|
||||
});
|
||||
|
||||
it("未知 code → toast.error 描述退化成 unknown 文案", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "some_unmapped_code",
|
||||
message: "x",
|
||||
});
|
||||
renderDetail();
|
||||
fireEvent.click(screen.getByTestId("model-detail-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("下載失敗,請稍後再試");
|
||||
});
|
||||
|
||||
it("this model 下載中(downloadingId === id)→ 按鈕 disabled 且顯示「下載中」", () => {
|
||||
useModelStore.setState({ downloadingId: "m1", selectedModel: convertedReady });
|
||||
renderDetail();
|
||||
const btn = screen.getByTestId("model-detail-download");
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent("下載中");
|
||||
});
|
||||
|
||||
it("其他下載中(downloadingId != null 且 != id)→ 按鈕 disabled", () => {
|
||||
useModelStore.setState({ downloadingId: "other-model", selectedModel: convertedReady });
|
||||
renderDetail();
|
||||
expect(screen.getByTestId("model-detail-download")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@ -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() {
|
||||
// 詳細頁外層無 <Link>,不需 preventDefault / stopPropagation(與卡片差異點)。
|
||||
if (!selectedModel || downloadBusy || isAnyDownloading) return;
|
||||
|
||||
setDownloadBusy(true);
|
||||
const result = await downloadModel(selectedModel);
|
||||
setDownloadBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
toast.success(t("models.download.toast.start"), {
|
||||
description: t("models.download.toast.hint"),
|
||||
});
|
||||
} else {
|
||||
// 用 backend code 對應 i18n;找不到對應 key 時退化到 unknown(與卡片一致)。
|
||||
const key = `models.download.error.${result.code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("models.download.error.title"), {
|
||||
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
const ok = await deleteModel(id);
|
||||
@ -126,6 +157,30 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{downloadable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isAnyDownloading || downloadBusy}
|
||||
aria-label={t("models.action.download.aria")}
|
||||
data-testid="model-detail-download"
|
||||
>
|
||||
{isDownloading || downloadBusy ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("models.action.downloading")} />
|
||||
{t("models.action.downloading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("models.action.download")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={deleting}>
|
||||
@ -147,6 +202,7 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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<typeof import("@/lib/api")>("@/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
api: { ...actual.api, get: vi.fn(), post: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { getModelDownload, triggerNavDownload } from "@/lib/api/model-download";
|
||||
import { 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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<string>("target_chip", "targetChip");
|
||||
return {
|
||||
id: String(pick<string>("id") ?? ""),
|
||||
name: String(pick<string>("name") ?? ""),
|
||||
targetChip: (pick<string>("target_chip", "targetChip") as TargetChip) ?? "unknown",
|
||||
targetChip: (rawTargetChip?.toLowerCase() as TargetChip) ?? "unknown",
|
||||
fileSize: Number(pick<number>("file_size", "fileSize") ?? 0),
|
||||
source: (pick<string>("source") as ModelSource) ?? "uploaded",
|
||||
status: (pick<string>("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<ModelSummary, "source" | "status">): boolean {
|
||||
if (model.source === "preset") return true;
|
||||
return model.source === "converted" && model.status === "ready";
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user