jim800121chen c63886a194 feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。

B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
  scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)

B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501

download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態

測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。

技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:06:09 +08:00

563 lines
19 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"
"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 {
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)
}
}
// ModelDownloadResponse 是 GET /api/models/:id/download 的 response dataADR-017 (a) 決策 2
//
// Clientlocal-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 tokenfdt_放 Authorization: Bearer。
Token string `json:"token"`
// ExpiresAt 是 token 到期時間RFC3339 / UTCMC 沒回填時為零值(前端不應依賴)。
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
// modelsDownloadHandler 實作 GET /api/models/:id/downloadADR-017 (a) 模型庫直連 FAA 下載)。
//
// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2
// 1. ownership 驗(第一階段 owner-onlyB 分享是後續階段)
// 2. model 必須有 FAAObjectKey= 轉檔→promote 類);上傳類(空 key回 501Q7 範圍框死)
// 3. FileAccessIssuer 簽 MC download tokenfdt_
// 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
}
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"get model failed: "+err.Error(), nil)
return
}
// 第一階段 owner-onlyB 分享後續階段);非 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 tokenfdt_。userID = OIDC subMC 真實 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}.nefuserID(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, "/")
}