- 新端点 /ai/v1/audio/speech(xai.grok-tts)、/rerank(cohere.rerank-v4)、/moderations(OCI Guardrails) - Responses 放行 code_interpreter 与远程 mcp 工具,web_search/x_search 解除仅非流式限制 - 模型能力映射扩展:TEXT_RERANK→RERANK、TEXT_TO_AUDIO→TTS - AI 网关文档独立 docs/ai-gateway.md,字段兼容矩阵只列支持项;README 精简引用 - swagger 修缺:135 处响应注解具体化,RawMessage/联合类型统一渲染 AnyJSON,overrides 迁至 docs/.swaggo - CHANGELOG 0.4.0,版本段不再记日期;DASH_VERSION v0.4.0
315 lines
9.8 KiB
Go
315 lines
9.8 KiB
Go
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/model" // swagger 注解引用
|
|
|
|
"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} model.OciConfig
|
|
// @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 {array} service.ConfigSummary
|
|
// @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} model.OciConfig
|
|
// @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} configChangesResponse "config 与 changes 字段变更对照"
|
|
// @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} configChangesResponse "config 与 changes 字段变更对照"
|
|
// @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 {array} oci.Compartment
|
|
// @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 {array} service.RegionSubscriptionView
|
|
// @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 {array} oci.Compartment
|
|
// @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)
|
|
}
|