// models.go — /api/models/* 的 handler 實作。 // // 雛形重點: // - GET /api/models:列當前 user 的模型(ModelRepo in-memory) // - GET /api/models/:id:取單一模型 metadata // - POST /api/models/init:兩階段上傳第一步 — 驗證輸入、產 storageKey 與 presigned PUT URL // - POST /api/models/:id/finalize:第二步 — 驗證檔案已存在(storage.Exists)與大小,標為 ready // - DELETE /api/models/:id:軟刪 // // **兩階段上傳(Init → PUT → Finalize)的設計理由**: // - 讓前端直接 PUT 到 storage,不佔 api-server 記憶體 / bandwidth // - Phase 0 LocalFS 用假 presigned URL;Phase 1 S3 用原生 presigned URL // // 對齊 api-spec.md §4、feature-model-management.md。 package api import ( "context" "encoding/json" "errors" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "visiona-backend/internal/model" "visiona-backend/internal/storage" ) // modelUploadURLTTL 是 presigned PUT URL 的存活時間。 const modelUploadURLTTL = 15 * time.Minute // registerModelRoutes 註冊 /api/models/* 的 routes。 func registerModelRoutes(g *gin.RouterGroup, deps Deps) { g.GET("/models", modelsListHandler(deps)) g.GET("/models/:id", modelsGetHandler(deps)) g.POST("/models/init", modelsInitUploadHandler(deps)) g.POST("/models/:id/finalize", modelsFinalizeHandler(deps)) g.DELETE("/models/:id", modelsDeleteHandler(deps)) // Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。 g.GET("/models/:id/download", modelsDownloadHandler(deps)) // load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent) g.POST("/models/:id/load-to-device", func(c *gin.Context) { WriteNotImplemented(c, "models.load-to-device — pending Phase 1") }) } // ModelResponse 是 API 回傳的 Model DTO;對應 api-spec.md §4 的格式。 type ModelResponse struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` TargetChip string `json:"target_chip,omitempty"` FileSize int64 `json:"file_size"` Source string `json:"source"` Status string `json:"status"` // "pending" / "ready" CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` UploadedAt *time.Time `json:"uploaded_at,omitempty"` } // toModelResponse 把 domain model 轉為 API DTO;「status」由 UploadedAt 是否 set 決定。 func toModelResponse(m *model.Model) ModelResponse { status := "pending" if m.UploadedAt != nil { status = "ready" } return ModelResponse{ ID: m.ID, Name: m.Name, Description: m.Description, TargetChip: m.TargetChip, FileSize: m.FileSize, Source: m.Source, Status: status, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, UploadedAt: m.UploadedAt, } } // modelsListHandler 實作 GET /api/models。 func modelsListHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil { WriteSuccess(c, http.StatusOK, []ModelResponse{}) 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() models, err := deps.ModelRepo.List(ctx, model.ListFilter{OwnerUserID: userID}) if err != nil { // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "list models", err) return } out := make([]ModelResponse, 0, len(models)) for _, m := range models { out = append(out, toModelResponse(m)) } WriteSuccess(c, http.StatusOK, out) } } // modelsGetHandler 實作 GET /api/models/:id。 func modelsGetHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() m, err := deps.ModelRepo.Get(ctx, id) if err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } WriteSuccess(c, http.StatusOK, toModelResponse(m)) } } // ModelInitRequest 是 POST /api/models/init 的 request body。 type ModelInitRequest struct { Name string `json:"name"` FileSize int64 `json:"file_size"` Checksum string `json:"checksum,omitempty"` // sha256 hex(Phase 1 驗) TargetChip string `json:"target_chip,omitempty"` Description string `json:"description,omitempty"` } // ModelInitResponse 是 POST /api/models/init 的 response data。 type ModelInitResponse struct { ModelID string `json:"model_id"` UploadURL string `json:"upload_url"` UploadExpiresAt time.Time `json:"upload_expires_at"` StorageKey string `json:"storage_key"` } // modelsInitUploadHandler 實作 POST /api/models/init。 // // 流程: // 1. 驗證 request(name 必填、file_size 不能超過配置) // 2. 產新 model_id + storage_key(`models/{userID}/{modelID}.nef`) // 3. 用 storage.PresignedPutURL 取 PUT URL // 4. 在 ModelRepo 建立 pending 紀錄(UploadedAt = nil) // 5. 回應 model_id + upload_url // // 錯誤:413 PAYLOAD_TOO_LARGE、400 VALIDATION_FAILED、501(storage/repo 未設) func modelsInitUploadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil || deps.Storage == nil { WriteNotImplemented(c, "model repo or storage not configured") return } var req ModelInitRequest if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil) return } // 驗證 name if strings.TrimSpace(req.Name) == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "name is required", []FieldError{{Field: "name", Message: "cannot be empty"}}) return } // 驗證 file_size > 0 且不超過上限 if req.FileSize <= 0 { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "file_size must be > 0", []FieldError{{Field: "file_size", Message: "must be positive"}}) return } // 大小上限檢查(MaxUploadSizeMB 取自 Deps;若為 0 則不限,給測試友善) if deps.MaxUploadSizeMB > 0 { limit := int64(deps.MaxUploadSizeMB) * 1024 * 1024 if req.FileSize > limit { WriteError(c, http.StatusRequestEntityTooLarge, ErrCodePayloadTooLarge, "file_size exceeds upload limit", []FieldError{{Field: "file_size", Message: "max allowed is configured by server"}}) 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID modelID := uuid.NewString() storageKey := "models/" + userID + "/" + modelID + ".nef" ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() // 產 presigned PUT URL uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL) if err != nil { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "presigned url failed: "+err.Error(), nil) return } // 建立 pending 紀錄 now := time.Now().UTC() m := &model.Model{ ID: modelID, OwnerUserID: userID, Name: req.Name, Description: req.Description, TargetChip: req.TargetChip, FileSize: req.FileSize, FileChecksum: req.Checksum, StorageKey: storageKey, Source: model.SourceUploaded, CreatedAt: now, UpdatedAt: now, // UploadedAt 保持 nil 直到 finalize } if err := deps.ModelRepo.Save(ctx, m); err != nil { // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "save model", err) return } logOrDefault(deps.Logger).Info("models: upload init", "model_id", modelID, "user_id", userID, "file_size", req.FileSize, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, ModelInitResponse{ ModelID: modelID, UploadURL: uploadURL, UploadExpiresAt: now.Add(modelUploadURLTTL), StorageKey: storageKey, }) } } // modelsFinalizeHandler 實作 POST /api/models/:id/finalize。 // // 流程: // 1. 取 model(ownership 檢查) // 2. 透過 storage.Stat 驗證檔案已存在 // 3. 驗證 Stat().Size == model.FileSize(雛形只做 size 比對;Phase 1 加 checksum) // 4. 更新 UploadedAt;存回 Repo // // 錯誤: // - 檔案還沒 PUT → 400 VALIDATION_FAILED (file not uploaded) // - Size 不符 → 400 VALIDATION_FAILED (size mismatch) func modelsFinalizeHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil || deps.Storage == nil { WriteNotImplemented(c, "model repo or storage not configured") 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() m, err := deps.ModelRepo.Get(ctx, id) if err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } // 驗證檔案已存在 obj, statErr := deps.Storage.Stat(ctx, m.StorageKey) if statErr != nil { if errors.Is(statErr, storage.ErrNotFound) { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "file not uploaded yet; PUT to upload_url first", nil) return } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "stat storage failed: "+statErr.Error(), nil) return } // Size 驗證(雛形只比對 size;Phase 1 加 checksum) if obj.Size != m.FileSize { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "uploaded size mismatch", []FieldError{ {Field: "file_size", Message: "declared vs actual differ"}, }) return } // 標記 ready now := time.Now().UTC() m.UploadedAt = &now m.UpdatedAt = now if err := deps.ModelRepo.Save(ctx, m); err != nil { // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "save model", err) return } logOrDefault(deps.Logger).Info("models: upload finalized", "model_id", m.ID, "user_id", userID, "size", m.FileSize, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, toModelResponse(m)) } } // modelsDeleteHandler 實作 DELETE /api/models/:id。 // // 雛形行為:軟刪(ModelRepo.Delete 已做軟刪)。 // 是否一併刪 storage 檔案 — 雛形保留檔案(方便 debug);Phase 1 接 S3 後, // 建議由後台 worker 在 grace period 後刪除(避免使用者誤刪)。 func modelsDeleteHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil { WriteNotImplemented(c, "model repo not configured") 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() // Ownership 檢查 m, err := deps.ModelRepo.Get(ctx, id) if err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } if err := deps.ModelRepo.Delete(ctx, id); err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "delete model", err) return } logOrDefault(deps.Logger).Info("models: deleted", "model_id", id, "user_id", userID, "request_id", RequestIDFrom(c)) c.Status(http.StatusNoContent) } } // ModelDownloadResponse 是 GET /api/models/:id/download 的 response data(ADR-017 (a) 決策 2)。 // // Client(local-tool / browser)拿到後,帶 `Authorization: Bearer {Token}` 直接 // GET DownloadURL(= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。 type ModelDownloadResponse struct { // DownloadURL 是 FAA 下載 URL(`{FAABaseURL}/files/{object_key}`)。 DownloadURL string `json:"download_url"` // Token 是 MC 簽的 opaque download token(fdt_);放 Authorization: Bearer。 Token string `json:"token"` // ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。 ExpiresAt time.Time `json:"expires_at,omitempty"` } // modelsDownloadHandler 實作 GET /api/models/:id/download(ADR-017 (a) 模型庫直連 FAA 下載)。 // // 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2): // 1. ownership 驗(第一階段 owner-only;B 分享是後續階段) // 2. model 必須有 FAAObjectKey(= 轉檔→promote 類);上傳類(空 key)回 501(Q7 範圍框死) // 3. FileAccessIssuer 簽 MC download token(fdt_) // 4. 回 {download_url, token, expires_at} 給 Client 直連 FAA // // 錯誤: // - issuer / FAABaseURL 未配置(deps.FileAccessIssuer == nil)→ 501 NOT_IMPLEMENTED // - model 不存在 / 非 owner → 404 / 403 // - 上傳類 model(無 FAAObjectKey)→ 501 NOT_IMPLEMENTED(明確訊息) // - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態) func modelsDownloadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.ModelRepo == nil { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // FAA 直連下載未啟用(cfg.FileAccess.Enabled() == false → main.go 不 wire) if deps.FileAccessIssuer == nil || deps.FAABaseURL == "" { 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 == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() m, err := deps.ModelRepo.Get(ctx, id) if err != nil { if errors.Is(err, model.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "get model", err) return } // 第一階段 owner-only(B 分享後續階段);非 owner 回 403。 if m.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } // 第一階段只支援「轉檔→promote 進 FAA」類 model(有 FAAObjectKey)。 // 上傳類 model 只在 visionA 自己 storage、不在 FAA,無法直連(ADR-017 Q7 範圍框死)。 if m.FAAObjectKey == "" { WriteNotImplemented(c, "uploaded models do not support FAA direct download in this phase (only converted models)") return } // 簽 MC download token(fdt_)。userID = OIDC sub(MC 真實 user); // objectKey 必須與 FAA GET path key 一致(= 簽 token 時的 object_key)。 issued, err := deps.FileAccessIssuer.IssueDownloadToken(ctx, userID, m.FAAObjectKey) if err != nil { // 對外 mask 成 502(不洩漏「MC 不可用 / token 簽發細節」這類內部運維狀態); // SRE 從 server log 的 error 看 fileaccess sentinel 分類。 logOrDefault(deps.Logger).Warn("models: download token issue failed", "model_id", m.ID, "user_id", userID, "err", err.Error(), "request_id", RequestIDFrom(c)) WriteError(c, http.StatusBadGateway, ErrCodeInternalError, "download token service unavailable", nil) return } // 組對外 download_url:{FAABaseURL}/files/{object_key}。 // object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。 downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) logOrDefault(deps.Logger).Info("models: download token issued", "model_id", m.ID, "user_id", userID, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, ModelDownloadResponse{ DownloadURL: downloadURL, Token: issued.Token, ExpiresAt: issued.ExpiresAt, }) } } // escapeFAAObjectKey 對 FAA object_key 逐段 path-escape,保留 '/' 為 path separator。 // // object_key 形如 models/{userID}/{jobID}.nef;userID(OIDC sub) / jobID(UUID) 都是 // 安全字元,但仍逐段 url.PathEscape 防禦(避免任何特殊字元破壞 URL 結構)。 // 不整體 PathEscape 是因為那會把 '/' 變 %2F,破壞 FAA `/files/{**objectKey}` 的 path 比對。 func escapeFAAObjectKey(objectKey string) string { segs := strings.Split(objectKey, "/") for i, s := range segs { segs[i] = url.PathEscape(s) } return strings.Join(segs, "/") }