從 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>
434 lines
14 KiB
Go
434 lines
14 KiB
Go
// 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)
|
||
}
|
||
}
|