package api import ( "crypto/rand" "encoding/hex" "errors" "log" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/oracle/oci-go-sdk/v65/common" "gorm.io/gorm" "oci-portal/internal/oci" "oci-portal/internal/service" ) type ociConfigHandler struct { svc *service.OciConfigService } // importRequest 中 configIni 与显式字段二选一,私钥内容必填。 type importRequest struct { Alias string `json:"alias" binding:"required"` Group string `json:"group"` // 面板内自定义分组,可空 ConfigINI string `json:"configIni"` TenancyOCID string `json:"tenancyOcid"` UserOCID string `json:"userOcid"` Region string `json:"region"` Fingerprint string `json:"fingerprint"` PrivateKey string `json:"privateKey" binding:"required"` Passphrase string `json:"passphrase"` MultiRegion bool `json:"multiRegion"` // 开启后订阅区域入库缓存 MultiCompartment bool `json:"multiCompartment"` // 开启后 compartment 入库缓存 ProxyID *uint `json:"proxyId"` // 关联出站代理,null 直连 } // @Summary 导入租户配置 // @Tags 租户配置 // @Param body body importRequest true "API Key 配置(私钥密文落库)" // @Success 201 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs [post] func (h *ociConfigHandler) create(c *gin.Context) { var req importRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cfg, err := h.svc.Import(c.Request.Context(), service.ImportInput{ Alias: req.Alias, Group: req.Group, ConfigINI: req.ConfigINI, TenancyOCID: req.TenancyOCID, UserOCID: req.UserOCID, Region: req.Region, Fingerprint: req.Fingerprint, PrivateKey: req.PrivateKey, Passphrase: req.Passphrase, MultiRegion: req.MultiRegion, MultiCompartment: req.MultiCompartment, ProxyID: req.ProxyID, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, cfg) } // @Summary 租户配置列表 // @Tags 租户配置 // @Success 200 {object} map[string]any "items" // @Security BearerAuth // @Router /api/v1/oci-configs [get] func (h *ociConfigHandler) list(c *gin.Context) { items, err := h.svc.List(c.Request.Context()) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, items) } // @Summary 租户配置详情 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id} [get] func (h *ociConfigHandler) get(c *gin.Context) { id, ok := pathID(c) if !ok { return } cfg, err := h.svc.Get(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, cfg) } // @Summary 验证配置连通性并刷新画像 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/verify [post] func (h *ociConfigHandler) verify(c *gin.Context) { id, ok := pathID(c) if !ok { return } cfg, changes, err := h.svc.Verify(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"config": cfg, "changes": changes}) } // updateConfigRequest 空字段不变更;替换私钥须同时给新指纹; // passphrase / group 一经提供整体覆盖(空串分别表示清除口令 / 取消分组)。 type updateConfigRequest struct { Alias string `json:"alias"` Group *string `json:"group"` Region string `json:"region"` Fingerprint string `json:"fingerprint"` PrivateKey string `json:"privateKey"` Passphrase *string `json:"passphrase"` MultiRegion *bool `json:"multiRegion"` // 非 null 时切换多区域支持 MultiCompartment *bool `json:"multiCompartment"` // 非 null 时切换多区间支持 ProxyID *uint `json:"proxyId"` // 非 null 切换代理:0 解除,>0 关联 } // @Summary 更新租户配置 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id} [put] func (h *ociConfigHandler) update(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req updateConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cfg, changes, err := h.svc.Update(c.Request.Context(), id, service.UpdateInput{ Alias: req.Alias, Group: req.Group, Region: req.Region, Fingerprint: req.Fingerprint, PrivateKey: req.PrivateKey, Passphrase: req.Passphrase, MultiRegion: req.MultiRegion, MultiCompartment: req.MultiCompartment, ProxyID: req.ProxyID, }) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"config": cfg, "changes": changes}) } // @Summary 删除租户配置 // @Description 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 204 "无内容" // @Security BearerAuth // @Router /api/v1/oci-configs/{id} [delete] func (h *ociConfigHandler) remove(c *gin.Context) { id, ok := pathID(c) if !ok { return } if err := h.svc.Delete(c.Request.Context(), id); err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) } // compartments 列出租户下全部 ACTIVE compartment(不含租户根)。 // // @Summary 列出租户下全部 ACTIVE compartment // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/compartments [get] func (h *ociConfigHandler) compartments(c *gin.Context) { id, ok := pathID(c) if !ok { return } items, err := h.svc.Compartments(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, items) } // cachedRegions 返回筛选器用区域列表:未开多区域支持只含默认区域; // 缓存存在非 READY 时实时刷新。 // // @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/cached-regions [get] func (h *ociConfigHandler) cachedRegions(c *gin.Context) { id, ok := pathID(c) if !ok { return } items, err := h.svc.CachedRegions(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, items) } // cachedCompartments 返回筛选器用区间列表:未开多区间支持返回空数组。 // // @Summary 返回筛选器用区间列表:未开多区间支持返回空数组 // @Tags 租户配置 // @Param id path int true "配置 ID" // @Success 200 {object} map[string]any // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/cached-compartments [get] func (h *ociConfigHandler) cachedCompartments(c *gin.Context) { id, ok := pathID(c) if !ok { return } items, err := h.svc.CachedCompartments(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, items) } func pathID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return 0, false } return uint(id), true } // respondError 统一出错响应边界(S-08):OCI 服务错误经脱敏透出,记录缺失 // 映射 404;其余按内部错误处理——响应只含固定文案与关联 ID,完整原因 // (可能携带 SQL / DSN / 路径等内部细节)仅写服务端日志,经同一 ID 对应。 func respondError(c *gin.Context, err error) { var svcErr common.ServiceError if errors.As(err, &svcErr) { respondOCIError(c, err, svcErr) return } if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"}) return } id := newRequestID() log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err) c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id}) } // newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。 func newRequestID() string { b := make([]byte, 8) _, _ = rand.Read(b) return hex.EncodeToString(b) } // respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码, // error 只保留操作前缀 + 错误码 + 服务端消息(oci.CompactError)。 func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) { status := svcErr.GetHTTPStatusCode() if status < http.StatusBadRequest { status = http.StatusBadGateway } // 面板的 401 专属本地 JWT 失效(前端收到即登出)、429 专属本地 IP 限速 // (前端收到即跳 /blocked);上游 OCI 的同码是代理调用被拒/云端限流, // 均改用 502 透出,body 保留 ociCode 供辨识 if status == http.StatusUnauthorized || status == http.StatusTooManyRequests { status = http.StatusBadGateway } body := gin.H{ "error": oci.CompactError(err), "ociCode": svcErr.GetCode(), "opcRequestId": svcErr.GetOpcRequestID(), } if hint := oci.ErrorHint(err); hint != "" { body["hint"] = hint } c.JSON(status, body) }