// 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" "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)) // 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 { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "list models failed: "+err.Error(), nil) 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 } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "get model failed: "+err.Error(), nil) 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 { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "save model failed: "+err.Error(), nil) 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 } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "get model failed: "+err.Error(), nil) 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 { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "save model failed: "+err.Error(), nil) 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 } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "get model failed: "+err.Error(), nil) return } if m.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) return } if err := deps.ModelRepo.Delete(ctx, id); err != nil { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "delete model failed: "+err.Error(), nil) return } logOrDefault(deps.Logger).Info("models: deleted", "model_id", id, "user_id", userID, "request_id", RequestIDFrom(c)) c.Status(http.StatusNoContent) } }