jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

434 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 URLPhase 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 hexPhase 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. 驗證 requestname 必填、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、501storage/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. 取 modelownership 檢查)
// 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 驗證(雛形只比對 sizePhase 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 檔案 — 雛形保留檔案(方便 debugPhase 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)
}
}