jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

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

626 lines
23 KiB
Go
Raw Permalink 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"
// 模型 metadataB4 鏈路最後一個序列化點)。
// snake_case 對齊本 DTO 既有慣例target_chip / file_size / created_at
// 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。
// omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
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,
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
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 {
// B8即使無 user repo最小骨架7 個系統預設模型仍對所有人可見。
WriteSuccess(c, http.StatusOK, presetResponses())
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.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "list models", err)
return
}
// B8list = 7 個系統預設模型(公用、所有人可見、放最前面)+ 該 user 的 modelDB 查)。
presets := presetResponses()
out := make([]ModelResponse, 0, len(presets)+len(models))
out = append(out, presets...)
for _, m := range models {
out = append(out, toModelResponse(m))
}
WriteSuccess(c, http.StatusOK, out)
}
}
// presetResponses 把 7 個系統預設模型轉成 API DTO順序固定
func presetResponses() []ModelResponse {
presets := model.PresetModels()
out := make([]ModelResponse, 0, len(presets))
for _, m := range presets {
out = append(out, toModelResponse(m))
}
return 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
}
// B8preset 是公用模型(無 owner、不在 DB任何已登入 user 都查得到,不做 ownership 檢查。
if pm, ok := model.PresetByID(id); ok {
WriteSuccess(c, http.StatusOK, toModelResponse(pm))
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.4DB 錯誤經 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 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 {
// 塊 5 Minor-1storage 錯誤統一映射,不把 raw errbucket/endpoint 等)洩漏給前端。
WriteStorageError(c, deps.Logger, "presigned put url", err)
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.4DB 錯誤經 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. 取 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
}
// 塊 5.4DB 錯誤經 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
}
// 塊 5 Minor-1storage 錯誤統一映射,不把 raw errbucket/endpoint 等)洩漏給前端。
WriteStorageError(c, deps.Logger, "stat object", statErr)
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 {
// 塊 5.4DB 錯誤經 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 檔案 — 雛形保留檔案(方便 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
}
// 塊 5.4DB 錯誤經 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.4DB 錯誤經 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 v1.3§11瀏覽器下載改 query-string token + redirect 導航,避開 CORS
// preflight 405。後端組好「已含 ?access_token= 的完整 URL」決策 A 乙案Client
// 把 DownloadURL 當不透明連結直接導航下載——不經 visionA、不經 AWS。
type ModelDownloadResponse struct {
// DownloadURL 是 FAA 下載 URL已含 `?access_token=``{FAABaseURL}/files/{object_key}?access_token={token}`)。
// 可直接瀏覽器導航下載,無需自行帶 header / 拼 queryADR-017 §11.3 乙案)。
DownloadURL string `json:"download_url"`
// Token 是 MC 簽的 opaque download tokenfdt_
//
// DeprecatedADR-017 v1.3 §11.3token 已併入 DownloadURL 的 ?access_token=
// Client 不應再單獨使用本欄位。保留純為向下相容(舊 local-tool / 既有 consumer 可能仍讀)。
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) {
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
return
}
// B8preset 是公用模型download 走 visionA 自己(簡單版不簽 token
// 必須在 FAA / ModelRepo nil 檢查之前處理——preset 完全不依賴 FAA / DB。
if pm, ok := model.PresetByID(id); ok {
if deps.PresetModelsDir == "" || deps.PresetBaseURL == "" {
// preset 檔案目錄 / base URL 未配置main.go 沒 wire→ 無從組可下載 URL。
WriteNotImplemented(c, "preset model download not configured (set VISIONA_PRESET_MODELS_DIR)")
return
}
// preset StorageKey = models/preset/{id}.nef對外 URL 用檔名 {id}.nef。
downloadURL := strings.TrimRight(deps.PresetBaseURL, "/") + "/preset-models/" + url.PathEscape(id) + ".nef"
logOrDefault(deps.Logger).Info("models: preset download",
"model_id", pm.ID,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
DownloadURL: downloadURL,
// preset 不簽 tokenToken / ExpiresAt 留空(前端只需 DownloadURL 即可下載)。
})
return
}
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
}
// 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.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "get model", err)
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}?access_token={token}。
// object_key 內含 '/'models/{userID}/{jobID}.nef需逐段 escape 但保留 '/'。
// ADR-017 v1.3§11.3 乙案token 以 query string 併入 URLFAA 原生支援 access_token
// query、§11.2),由後端用 url.QueryEscape 統一 escape對齊 FAA test page 的
// Uri.EscapeDataString。path 此時不含 query直接 append `?access_token=` 即可。
// 注意downloadURL含 token絕不寫進 logtoken 維持不洩漏原則)。
downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) +
"?access_token=" + url.QueryEscape(issued.Token)
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, "/")
}