jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

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

577 lines
20 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"
// 模型 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 {
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 {
// 塊 5.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "list models", err)
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
}
// 塊 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 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
}
// 塊 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}。
// 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, "/")
}