package api import ( "errors" "io" "net/http" "strconv" "github.com/gin-gonic/gin" "oci-portal/internal/oci" "oci-portal/internal/service" ) // ---- 对象存储 ---- // @Summary 对象存储 namespace // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param region query string false "区域" // @Success 200 {object} map[string]string // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/object-storage/namespace [get] func (h *ociConfigHandler) osNamespace(c *gin.Context) { id, ok := pathID(c) if !ok { return } ns, err := h.svc.ObjectStorageNamespace(c.Request.Context(), id, c.Query("region")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"namespace": ns}) } // @Summary 存储桶列表 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param region query string false "区域" // @Param compartmentId query string false "区间 OCID,空为生效区间" // @Success 200 {array} oci.Bucket // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets [get] func (h *ociConfigHandler) listBuckets(c *gin.Context) { id, ok := pathID(c) if !ok { return } buckets, err := h.svc.Buckets(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, buckets) } type createBucketRequest struct { Region string `json:"region"` CompartmentID string `json:"compartmentId"` Name string `json:"name" binding:"required"` PublicRead bool `json:"publicRead"` StorageTier string `json:"storageTier"` // Standard / Archive VersioningOn bool `json:"versioningOn"` } // @Summary 创建存储桶 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param body body createBucketRequest true "请求体" // @Success 201 {object} oci.Bucket // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets [post] func (h *ociConfigHandler) createBucket(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req createBucketRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } bucket, err := h.svc.CreateBucket(c.Request.Context(), id, req.Region, oci.CreateBucketInput{ Name: req.Name, CompartmentID: req.CompartmentID, PublicRead: req.PublicRead, StorageTier: req.StorageTier, VersioningOn: req.VersioningOn, }) if err != nil { respondError(c, err) return } c.JSON(http.StatusCreated, bucket) } type updateBucketRequest struct { Region string `json:"region"` PublicRead *bool `json:"publicRead"` VersioningOn *bool `json:"versioningOn"` } // @Summary 更新存储桶(可见性/版本控制) // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param body body updateBucketRequest true "请求体" // @Success 200 {object} oci.Bucket // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket} [put] func (h *ociConfigHandler) updateBucket(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req updateBucketRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } bucket, err := h.svc.UpdateBucket(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.UpdateBucketInput{ PublicRead: req.PublicRead, VersioningOn: req.VersioningOn, }) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, bucket) } // @Summary 删除存储桶(非空桶后台清空后删除) // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Success 200 {object} map[string]bool "queued=true 表示已转后台清空删除" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket} [delete] func (h *ociConfigHandler) deleteBucket(c *gin.Context) { id, ok := pathID(c) if !ok { return } queued, err := h.svc.DeleteBucket(c.Request.Context(), id, c.Query("region"), c.Param("bucket")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"queued": queued}) } // @Summary 对象列表(前缀模式分页) // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param prefix query string false "前缀(虚拟目录)" // @Param startWith query string false "上页 nextStartWith 游标" // @Param limit query int false "每页数量,默认 100" // @Success 200 {object} oci.ListObjectsResult // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [get] func (h *ociConfigHandler) listObjects(c *gin.Context) { id, ok := pathID(c) if !ok { return } limit, _ := strconv.Atoi(c.Query("limit")) result, err := h.svc.Objects(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("prefix"), c.Query("startWith"), limit) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, result) } // @Summary 删除对象 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param object query string true "对象名(完整 key)" // @Success 204 "删除成功" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [delete] func (h *ociConfigHandler) deleteObject(c *gin.Context) { id, ok := pathID(c) if !ok { return } err := h.svc.DeleteObject(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object")) if err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) } // @Summary 重命名对象 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param body body object true "请求体:region、source、newName" // @Success 204 "重命名成功" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename [post] func (h *ociConfigHandler) renameObject(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req struct { Region string `json:"region"` Source string `json:"source" binding:"required"` NewName string `json:"newName" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } err := h.svc.RenameObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Source, req.NewName) if err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) } // @Summary 取回 Archive 对象 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param body body object true "请求体:region、object、hours(可下载时长,默认 24)" // @Success 202 "取回已提交,约 1 小时后可下载" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore [post] func (h *ociConfigHandler) restoreObject(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req struct { Region string `json:"region"` Object string `json:"object" binding:"required"` Hours int `json:"hours"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } err := h.svc.RestoreObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Object, req.Hours) if err != nil { respondError(c, err) return } c.Status(http.StatusAccepted) } // @Summary 对象元数据 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param object query string true "对象名(完整 key)" // @Success 200 {object} oci.ObjectDetail // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail [get] func (h *ociConfigHandler) objectDetail(c *gin.Context) { id, ok := pathID(c) if !ok { return } detail, err := h.svc.ObjectDetail(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, detail) } // maxPutContentBody 是保存对象内容的请求体上限(与 service 层 5MB 限制一致)。 const maxPutContentBody = 5 << 20 // respondContentError 对象内容中转专属错误:超限映射 413,其余走通用处理。 func respondContentError(c *gin.Context, err error) { if errors.Is(err, oci.ErrObjectTooLarge) { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "对象超出面板中转大小上限"}) return } respondError(c, err) } // @Summary 读取对象内容(面板中转) // @Description 预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param object query string true "对象名(完整 key)" // @Success 200 {string} string "对象原始内容" // @Failure 413 {object} map[string]string "对象超出中转上限" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [get] func (h *ociConfigHandler) getObjectContent(c *gin.Context) { id, ok := pathID(c) if !ok { return } content, err := h.svc.ObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object")) if err != nil { respondContentError(c, err) return } ct := content.ContentType if ct == "" { ct = "application/octet-stream" } c.Header("ETag", content.Etag) c.Data(http.StatusOK, ct, content.Data) } // @Summary 保存对象内容(面板中转) // @Description 文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param object query string true "对象名(完整 key)" // @Success 200 {object} map[string]string "etag=新 ETag" // @Failure 412 {object} map[string]string "If-Match 不匹配,对象已被并发修改" // @Failure 413 {object} map[string]string "请求体超出上限" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [put] func (h *ociConfigHandler) putObjectContent(c *gin.Context) { id, ok := pathID(c) if !ok { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxPutContentBody) data, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "请求体超出 5MB 上限"}) return } etag, err := h.svc.PutObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"), data, c.ContentType(), c.GetHeader("If-Match")) if err != nil { respondContentError(c, err) return } c.JSON(http.StatusOK, gin.H{"etag": etag}) } type createPARRequest struct { Region string `json:"region"` Name string `json:"name"` ObjectName string `json:"objectName"` // 空 = 桶级 AccessType string `json:"accessType" binding:"required"` ExpiresHours int `json:"expiresHours"` } // @Summary 签发临时链接(PAR) // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param body body createPARRequest true "请求体" // @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存" // @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post] func (h *ociConfigHandler) createPAR(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req createPARRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } par, err := h.svc.CreatePAR(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.CreatePARInput{ Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours, }) if err != nil { respondPARError(c, err) return } c.JSON(http.StatusCreated, par) } func respondPARError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrPARInvalidAccessType): c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"}) case errors.Is(err, service.ErrPARInvalidExpiration): c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"}) default: respondError(c, err) } } // parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。 type parPage struct { Items []oci.PAR `json:"items"` NextPage string `json:"nextPage"` } // @Summary 临时链接列表 // @Description 游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param region query string false "区域" // @Param page query string false "分页游标(上次响应的 nextPage,空取首页)" // @Param limit query int false "每页条数,默认 100,上限 1000" // @Success 200 {object} parPage // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [get] func (h *ociConfigHandler) listPARs(c *gin.Context) { id, ok := pathID(c) if !ok { return } limit, _ := strconv.Atoi(c.Query("limit")) items, next, err := h.svc.PARsPage(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("page"), limit) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, parPage{Items: items, NextPage: next}) } // @Summary 删除临时链接 // @Description all=1 时删除桶内全部 PAR 并返回 {"deleted": n};否则按 parId 删单条 // @Tags 对象存储 // @Param id path int true "配置 ID" // @Param bucket path string true "桶名" // @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)" // @Param all query string false "为 1 时删除全部" // @Param region query string false "区域" // @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}" // @Success 204 "按 parId 删单条成功,链接立即失效" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete] func (h *ociConfigHandler) deletePAR(c *gin.Context) { id, ok := pathID(c) if !ok { return } if c.Query("all") == "1" { n, err := h.svc.DeleteAllPARs(c.Request.Context(), id, c.Query("region"), c.Param("bucket")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"deleted": n}) return } err := h.svc.DeletePAR(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("parId")) if err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) }