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