初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 构建信息由编译参数注入:
|
||||
// go build -ldflags "-X oci-portal/internal/api.buildVersion=v1.0.0 -X oci-portal/internal/api.buildTime=2026-07-09T12:00Z"
|
||||
var (
|
||||
buildVersion = "dev"
|
||||
buildTime = ""
|
||||
)
|
||||
|
||||
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||
func about(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"version": buildVersion,
|
||||
"buildTime": buildTime,
|
||||
"goVersion": runtime.Version(),
|
||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// aiAdminHandler 处理面板侧 AI 网关管理接口(secured 组,自动进系统日志)。
|
||||
type aiAdminHandler struct {
|
||||
gw *service.AiGatewayService
|
||||
}
|
||||
|
||||
// ---- 密钥 ----
|
||||
|
||||
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
||||
keys, err := h.gw.Keys(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": keys})
|
||||
}
|
||||
|
||||
// createKey 生成密钥;明文仅在本响应返回一次。
|
||||
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Value string `json:"value"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"key": plaintext, "item": key})
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) updateKey(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Group *string `json:"group"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) deleteKey(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.gw.DeleteKey(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateKeyContentLog 设置密钥内容日志窗口(红线例外:显式限时开启,0 关闭)。
|
||||
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Hours int `json:"hours"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
key, err := h.gw.UpdateKeyContentLog(c.Request.Context(), id, req.Hours)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, key)
|
||||
}
|
||||
|
||||
// listContentLogs 分页查询内容日志(可选 keyId / callLogId 过滤)。
|
||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
keyID, _ := strconv.Atoi(c.DefaultQuery("keyId", "0"))
|
||||
callLogID, _ := strconv.Atoi(c.DefaultQuery("callLogId", "0"))
|
||||
items, total, err := h.gw.ContentLogs(c.Request.Context(), uint(keyID), uint(callLogID), page, size)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
// ---- 渠道 ----
|
||||
|
||||
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
||||
chs, err := h.gw.Channels(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": chs})
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
||||
var req service.ChannelInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ch, err := h.gw.CreateChannel(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, ch)
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) updateChannel(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.ChannelInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gw.UpdateChannel(c.Request.Context(), id, req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.gw.DeleteChannel(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// probeChannel 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道。
|
||||
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, err := h.gw.ProbeChannel(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
models, err := h.gw.SyncModels(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||
}
|
||||
|
||||
// ---- 聚合模型与调用日志 ----
|
||||
|
||||
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
||||
list, err := h.gw.GatewayModels(c.Request.Context(), "")
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
||||
}
|
||||
|
||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
items, total, err := h.gw.CallLogs(c.Request.Context(), page, size)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func aiPathID(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
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// aiGatewayHandler 处理 /ai/v1/* 对外网关端点(OpenAI / Anthropic 协议)。
|
||||
type aiGatewayHandler struct {
|
||||
gw *service.AiGatewayService
|
||||
}
|
||||
|
||||
const aiKeyCtx = "aiKey"
|
||||
|
||||
// auth 是网关鉴权中间件:Bearer 或 x-api-key 双头识别,失败按端点协议返回错误体。
|
||||
func (h *aiGatewayHandler) auth(c *gin.Context) {
|
||||
key, err := h.gw.VerifyKey(c.Request.Context(), extractAiKey(c))
|
||||
if err != nil {
|
||||
aiError(c, http.StatusUnauthorized, "authentication_error", "无效或已禁用的 API 密钥")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(aiKeyCtx, key)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func extractAiKey(c *gin.Context) string {
|
||||
if header := c.GetHeader("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader("x-api-key"))
|
||||
}
|
||||
|
||||
// aiError 按端点协议输出错误体:/ai/v1/messages 用 Anthropic 格式,其余 OpenAI 格式。
|
||||
func aiError(c *gin.Context, status int, code, msg string) {
|
||||
if strings.HasSuffix(c.FullPath(), "/messages") {
|
||||
c.JSON(status, aiwire.AnthErrorBody{Type: "error", Error: aiwire.AnthErrorDetail{Type: code, Message: msg}})
|
||||
return
|
||||
}
|
||||
c.JSON(status, aiwire.ErrorBody{Error: aiwire.ErrorDetail{Message: msg, Type: code}})
|
||||
}
|
||||
|
||||
// upstreamError 把编排层错误映射为网关响应(未知模型 404 / 无渠道 503 / 上游状态透传)。
|
||||
func upstreamError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAiUnknownModel):
|
||||
aiError(c, http.StatusNotFound, "model_not_found", err.Error())
|
||||
case errors.Is(err, service.ErrAiNoChannel):
|
||||
aiError(c, http.StatusServiceUnavailable, "overloaded_error", err.Error())
|
||||
case errors.Is(err, service.ErrAiUnsupportedBlock):
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
default:
|
||||
if status, ok := oci.ServiceStatus(err); ok {
|
||||
aiError(c, status, "upstream_error", oci.CompactError(err))
|
||||
return
|
||||
}
|
||||
aiError(c, http.StatusBadGateway, "upstream_error", oci.CompactError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func aiRandID(prefix string) string {
|
||||
buf := make([]byte, 12)
|
||||
_, _ = rand.Read(buf)
|
||||
return prefix + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// keyGroup 取当前请求密钥的分组(空 = 不限分组)。
|
||||
func keyGroup(c *gin.Context) string {
|
||||
if key, ok := c.Get(aiKeyCtx); ok {
|
||||
return key.(*model.AiKey).Group
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
|
||||
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
||||
entry := model.AiCallLog{
|
||||
Endpoint: endpoint, Model: modelName, Stream: stream,
|
||||
ChannelID: meta.ChannelID, ChannelName: meta.ChannelName,
|
||||
Retries: meta.Retries, LatencyMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
}
|
||||
if key, ok := c.Get(aiKeyCtx); ok {
|
||||
k := key.(*model.AiKey)
|
||||
entry.KeyID, entry.KeyName = k.ID, k.Name
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
|
||||
func validateIR(ir aiwire.ChatRequest) error {
|
||||
if strings.TrimSpace(ir.Model) == "" {
|
||||
return fmt.Errorf("model 不能为空")
|
||||
}
|
||||
if len(ir.Messages) == 0 {
|
||||
return fmt.Errorf("messages 不能为空")
|
||||
}
|
||||
for _, m := range ir.Messages {
|
||||
if m.Content.HasUnsupported() {
|
||||
return service.ErrAiUnsupportedBlock
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
||||
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
||||
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
|
||||
keyVal, ok := c.Get(aiKeyCtx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := keyVal.(*model.AiKey)
|
||||
if key.ContentLogUntil == nil || time.Now().After(*key.ContentLogUntil) {
|
||||
return
|
||||
}
|
||||
entry := model.AiContentLog{CallLogID: callLogID, KeyID: key.ID, KeyName: key.Name, Endpoint: endpoint, Model: modelName, Stream: stream}
|
||||
if b, err := json.Marshal(reqBody); err == nil {
|
||||
entry.RequestBody = string(b)
|
||||
}
|
||||
if respBody != nil {
|
||||
if b, err := json.Marshal(respBody); err == nil {
|
||||
entry.ResponseBody = string(b)
|
||||
}
|
||||
}
|
||||
h.gw.LogContent(entry)
|
||||
}
|
||||
|
||||
// logFailure 记失败调用日志,并在密钥内容日志开启时留请求正文用于排障(错误信息已在 ErrMsg,不记响应)。
|
||||
func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, reqBody any) {
|
||||
entry.Status = c.Writer.Status()
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
|
||||
}
|
||||
|
||||
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点。
|
||||
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||
var ir aiwire.ChatRequest
|
||||
if err := c.ShouldBindJSON(&ir); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateIR(ir); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if ir.Stream {
|
||||
h.streamOpenAI(c, ir)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "openai", ir.Model, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, ir)
|
||||
return
|
||||
}
|
||||
resp.ID = aiRandID("chatcmpl-")
|
||||
if resp.Created == 0 {
|
||||
resp.Created = time.Now().Unix()
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
fillUsage(&entry, resp.Usage)
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "openai", ir.Model, false, ir, resp)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
entry.PromptTokens, entry.CompletionTokens, entry.TotalTokens = u.PromptTokens, u.CompletionTokens, u.TotalTokens
|
||||
entry.CachedTokens = u.CachedTokens()
|
||||
}
|
||||
|
||||
// embeddings 是 OpenAI /ai/v1/embeddings 端点(非流式)。
|
||||
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
||||
var req aiwire.EmbeddingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" || len(req.Input) == 0 {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 input 不能为空")
|
||||
return
|
||||
}
|
||||
if req.EncodingFormat != "" && req.EncodingFormat != "float" {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
||||
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
if resp.Usage != nil {
|
||||
entry.PromptTokens, entry.TotalTokens = resp.Usage.PromptTokens, resp.Usage.TotalTokens
|
||||
}
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "embeddings", req.Model, false, req, nil)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// streamOpenAI 以 OpenAI SSE 直通流式响应,末尾发 [DONE]。
|
||||
func (h *aiGatewayHandler) streamOpenAI(c *gin.Context, ir aiwire.ChatRequest) {
|
||||
start := time.Now()
|
||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "openai", ir.Model, true, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, ir)
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
sseHeaders(c)
|
||||
id, created := aiRandID("chatcmpl-"), time.Now().Unix()
|
||||
var usage *aiwire.Usage
|
||||
for {
|
||||
chunk, err := stream.Next()
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
entry.ErrMsg = err.Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
chunk.ID, chunk.Created = id, created
|
||||
if chunk.Usage != nil {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
writeSSEData(c, chunk)
|
||||
}
|
||||
c.Writer.WriteString("data: [DONE]\n\n")
|
||||
c.Writer.Flush()
|
||||
entry.Status = http.StatusOK
|
||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||
fillUsage(&entry, usage)
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "openai", ir.Model, true, ir, nil)
|
||||
}
|
||||
|
||||
func sseHeaders(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
func writeSSEData(c *gin.Context, v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Writer.WriteString("data: ")
|
||||
c.Writer.Write(b)
|
||||
c.Writer.WriteString("\n\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// messages 是 Anthropic /ai/v1/messages 端点。
|
||||
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
||||
var req aiwire.MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if req.MaxTokens <= 0 {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
||||
return
|
||||
}
|
||||
ir, err := service.AnthropicToIR(req)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateIR(ir); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
h.streamAnthropic(c, ir)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
fillUsage(&entry, resp.Usage)
|
||||
callID := h.gw.LogCall(entry)
|
||||
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
|
||||
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
|
||||
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
|
||||
start := time.Now()
|
||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, ir)
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
sseHeaders(c)
|
||||
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
|
||||
for {
|
||||
chunk, err := stream.Next()
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
entry.ErrMsg = err.Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
writeAnthEvents(c, st.Feed(chunk))
|
||||
}
|
||||
writeAnthEvents(c, st.Finish())
|
||||
entry.Status = http.StatusOK
|
||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||
usage := st.Usage()
|
||||
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
||||
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||
entry.CachedTokens = usage.CacheReadInputTokens
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "anthropic", ir.Model, true, ir, nil)
|
||||
}
|
||||
|
||||
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
||||
for _, ev := range events {
|
||||
b, err := json.Marshal(ev.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
||||
c.Writer.Write(b)
|
||||
c.Writer.WriteString("\n\n")
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
||||
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
||||
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
||||
if err != nil {
|
||||
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// responses 是 OpenAI /ai/v1/responses 端点(无状态子集)。
|
||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||
var req aiwire.RespRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
ir, err := service.ResponsesToIR(req)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateIR(ir); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
h.streamResponses(c, ir)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
fillUsage(&entry, resp.Usage)
|
||||
callID := h.gw.LogCall(entry)
|
||||
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
|
||||
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流。
|
||||
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
|
||||
start := time.Now()
|
||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, ir)
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
sseHeaders(c)
|
||||
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
|
||||
for {
|
||||
chunk, err := stream.Next()
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
entry.ErrMsg = err.Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
writeRespEvents(c, st.Feed(chunk))
|
||||
}
|
||||
writeRespEvents(c, st.Finish())
|
||||
entry.Status = http.StatusOK
|
||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||
fillUsage(&entry, st.Usage())
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "responses", ir.Model, true, ir, nil)
|
||||
}
|
||||
|
||||
func writeRespEvents(c *gin.Context, events []service.RespEvent) {
|
||||
for _, ev := range events {
|
||||
b, err := json.Marshal(ev.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
||||
c.Writer.Write(b)
|
||||
c.Writer.WriteString("\n\n")
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// ---- 实例 IP ----
|
||||
|
||||
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Region string `json:"region"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
ip, err := h.svc.ChangeInstancePublicIP(c.Request.Context(), id, req.Region, c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicIp": ip})
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Region string `json:"region"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
address, err := h.svc.AddInstanceIpv6(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Address)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"ipv6Address": address})
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteInstanceIpv6(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := h.svc.DeleteInstanceIpv6(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), c.Query("address"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- VNIC 管理 ----
|
||||
|
||||
type attachVnicRequest struct {
|
||||
Region string `json:"region"`
|
||||
oci.AttachVnicInput
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
vnics, err := h.svc.InstanceVnics(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, vnics)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req attachVnicRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
vnic, err := h.svc.AttachVnic(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.AttachVnicInput)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, vnic)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) detachVnic(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DetachVnic(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// addVnicIpv6 为指定 VNIC 添加 IPv6;body.address 留空自动分配。
|
||||
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Region string `json:"region"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
addr, err := h.svc.AddVnicIpv6(c.Request.Context(), id, req.Region, c.Param("vnicId"), req.Address)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"address": addr})
|
||||
}
|
||||
|
||||
// ---- 引导卷挂载 ----
|
||||
|
||||
type attachBootVolumeRequest struct {
|
||||
Region string `json:"region"`
|
||||
BootVolumeID string `json:"bootVolumeId" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
atts, err := h.svc.BootVolumeAttachments(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, atts)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req attachBootVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
att, err := h.svc.AttachBootVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.BootVolumeID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, att)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req attachBootVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
att, err := h.svc.ReplaceBootVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.BootVolumeID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, att)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) detachBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DetachBootVolume(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- 块卷挂载 ----
|
||||
|
||||
type attachVolumeRequest struct {
|
||||
Region string `json:"region"`
|
||||
VolumeID string `json:"volumeId" binding:"required"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
volumes, err := h.svc.BlockVolumes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, volumes)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
atts, err := h.svc.VolumeAttachments(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, atts)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req attachVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
att, err := h.svc.AttachVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.VolumeID, req.ReadOnly)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, att)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) detachVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DetachVolume(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
type authHandler struct {
|
||||
svc *service.AuthService
|
||||
logs *service.SystemLogService
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
||||
TotpCode string `json:"totpCode"`
|
||||
}
|
||||
|
||||
func (h *authHandler) login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode)
|
||||
if errors.Is(err, service.ErrTotpRequired) {
|
||||
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
||||
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrPasswordLoginDisabled) {
|
||||
h.recordLogin(c, req.Username, http.StatusForbidden, start)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLoginLocked) {
|
||||
h.recordLogin(c, req.Username, http.StatusTooManyRequests, start)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
h.recordLogin(c, req.Username, http.StatusUnauthorized, start)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.recordLogin(c, req.Username, http.StatusOK, start)
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||
}
|
||||
|
||||
// logout 把当前令牌拉黑至自然过期(secured 组内,写请求由系统日志中间件留痕)。
|
||||
func (h *authHandler) logout(c *gin.Context) {
|
||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if ok && token != "" {
|
||||
h.svc.Logout(token)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// recordLogin 显式记录一次登录成败——登录不在 secured 组内,
|
||||
// 系统日志中间件覆盖不到;登录失败是安全关键事件,必须留痕。
|
||||
func (h *authHandler) recordLogin(c *gin.Context, username string, status int, start time.Time) {
|
||||
errMsg := ""
|
||||
if status == http.StatusUnauthorized {
|
||||
errMsg = "用户名、密码或验证码错误"
|
||||
} else if status == http.StatusTooManyRequests {
|
||||
errMsg = "连续失败已锁定"
|
||||
} else if status == http.StatusForbidden {
|
||||
errMsg = "密码登录已禁用"
|
||||
}
|
||||
h.logs.Record(model.SystemLog{
|
||||
Username: username,
|
||||
Method: http.MethodPost,
|
||||
Path: requestPath(c),
|
||||
Status: status,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsg,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// authxHandler 处理两步验证与外部身份(OAuth)接口。
|
||||
type authxHandler struct {
|
||||
auth *service.AuthService
|
||||
oauth *service.OAuthService
|
||||
}
|
||||
|
||||
// ---- TOTP(JWT 组内) ----
|
||||
|
||||
// totpStatus 返回当前账号两步验证启用状态。
|
||||
func (h *authxHandler) totpStatus(c *gin.Context) {
|
||||
enabled, err := h.auth.TotpStatus(c.Request.Context(), c.GetString(usernameKey))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": enabled})
|
||||
}
|
||||
|
||||
// totpSetup 生成待激活密钥,返回手动输入码与 otpauth URI(前端渲染二维码)。
|
||||
func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||
secret, uri, err := h.auth.SetupTotp(c.Request.Context(), c.GetString(usernameKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTotpAlreadyOn) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauthUri": uri})
|
||||
}
|
||||
|
||||
// totpActivate 校验验证码并正式启用两步验证。
|
||||
func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.auth.ActivateTotp(c.Request.Context(), c.GetString(usernameKey), req.Code)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||
func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.auth.DisableTotp(c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTotpConfirm) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- 登录凭据(JWT 组内) ----
|
||||
|
||||
// getCredentials 返回登录凭据摘要:当前用户名与密码登录禁用态。
|
||||
func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||
disabled, err := h.auth.PasswordLoginDisabled(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": c.GetString(usernameKey),
|
||||
"passwordLoginDisabled": disabled,
|
||||
})
|
||||
}
|
||||
|
||||
// updateCredentials 修改用户名 / 密码;当前密码必验,改名后旧 JWT 随即失效(前端应重新登录)。
|
||||
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
var req service.UpdateCredentialsInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
|
||||
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCredentialInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Disabled *bool `json:"disabled" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled)
|
||||
if errors.Is(err, service.ErrNeedIdentity) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
|
||||
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
||||
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
|
||||
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
|
||||
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||
providers := h.oauth.Providers(c.Request.Context())
|
||||
disabled := false
|
||||
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && len(providers) > 0 {
|
||||
disabled = true
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled})
|
||||
}
|
||||
|
||||
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
|
||||
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider != "oidc" && provider != "github" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
|
||||
return
|
||||
}
|
||||
mode, username := "login", ""
|
||||
if c.Query("mode") == "bind" {
|
||||
name, ok := h.bearerUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
|
||||
return
|
||||
}
|
||||
mode, username = "bind", name
|
||||
}
|
||||
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": authURL})
|
||||
}
|
||||
|
||||
// bearerUser 从 Authorization 头解析用户名(authorize 在公开组,绑定模式手动校验)。
|
||||
func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if len(header) < 8 || header[:7] != "Bearer " {
|
||||
return "", false
|
||||
}
|
||||
username, err := h.auth.ParseToken(header[7:])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return username, true
|
||||
}
|
||||
|
||||
// oauthCallback 是 provider 回调落点(浏览器直达):完成登录或绑定后 302 回前端。
|
||||
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
token, _, mode, err := h.oauth.HandleCallback(
|
||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||
if err != nil {
|
||||
target := "/login"
|
||||
if mode == "bind" {
|
||||
target = "/settings"
|
||||
}
|
||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
// 绑定模式:回设置页安全 tab
|
||||
c.Redirect(http.StatusFound, "/settings?oauth=bound")
|
||||
return
|
||||
}
|
||||
// 登录模式:token 放 fragment,不进服务端日志与 Referer
|
||||
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||
func oauthErrText(err error) string {
|
||||
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||
if errors.Is(err, known) {
|
||||
return known.Error()
|
||||
}
|
||||
}
|
||||
return "登录失败,请重试"
|
||||
}
|
||||
|
||||
// identities 列出当前账号绑定的外部身份。
|
||||
func (h *authxHandler) identities(c *gin.Context) {
|
||||
items, err := h.oauth.Identities(c.Request.Context(), c.GetString(usernameKey))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// unbindIdentity 解绑外部身份;密码登录禁用期间不允许解绑最后一个身份。
|
||||
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrLastIdentity) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- OAuth provider 设置(JWT 组内) ----
|
||||
|
||||
// getOAuthSettings 返回 provider 配置(secret 只回设置态)。
|
||||
func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||
view, err := settings.OAuthView(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
||||
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||
var req service.UpdateOAuthInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := settings.UpdateOAuth(c.Request.Context(), req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.getOAuthSettings(c, settings)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---- 控制台连接(VNC / 串口) ----
|
||||
|
||||
type createConsoleConnectionRequest struct {
|
||||
Region string `json:"region"`
|
||||
SSHPublicKey string `json:"sshPublicKey" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createConsoleConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
conn, err := h.svc.CreateConsoleConnection(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.SSHPublicKey)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, conn)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connections, err := h.svc.ConsoleConnections(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, connections)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteConsoleConnection(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteConsoleConnection(c.Request.Context(), id, c.Query("region"), c.Param("connectionId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package api 承载 HTTP 路由、参数绑定、响应和中间件。
|
||||
package api
|
||||
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||
|
||||
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||
// 缺省即控制台默认(JIT 开启建用户不更新、分配 Administrators 组、
|
||||
// 名称 ID 格式无、NameID 映射用户名)。
|
||||
type createIdpRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Metadata string `json:"metadata" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
|
||||
NameIDFormat string `json:"nameIdFormat"`
|
||||
AssertionAttribute string `json:"assertionAttribute"`
|
||||
UserStoreAttribute string `json:"userStoreAttribute"`
|
||||
|
||||
JitEnabled *bool `json:"jitEnabled"`
|
||||
JitCreate *bool `json:"jitCreate"`
|
||||
JitUpdate *bool `json:"jitUpdate"`
|
||||
JitAssignAdminGroup *bool `json:"jitAssignAdminGroup"`
|
||||
JitMapEmail bool `json:"jitMapEmail"`
|
||||
}
|
||||
|
||||
func boolOr(v *bool, def bool) bool {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idps, err := h.svc.IdentityProviders(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, idps)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createIdpRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{
|
||||
Name: req.Name,
|
||||
Metadata: req.Metadata,
|
||||
Description: req.Description,
|
||||
IconURL: req.IconURL,
|
||||
NameIDFormat: req.NameIDFormat,
|
||||
AssertionAttribute: req.AssertionAttribute,
|
||||
UserStoreAttribute: req.UserStoreAttribute,
|
||||
JitEnabled: boolOr(req.JitEnabled, true),
|
||||
JitCreate: boolOr(req.JitCreate, true),
|
||||
JitUpdate: boolOr(req.JitUpdate, false),
|
||||
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||
JitMapEmail: req.JitMapEmail,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, idp)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, idp)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", `attachment; filename="oci-domain-saml-metadata.xml"`)
|
||||
c.Data(http.StatusOK, "application/xml", metadata)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rules)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IdentityProviderID string `json:"identityProviderId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ads, err := h.svc.AvailabilityDomains(c.Request.Context(), id, c.Query("region"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ads)
|
||||
}
|
||||
|
||||
// ---- 实例 ----
|
||||
|
||||
type createInstanceRequest struct {
|
||||
Region string `json:"region"`
|
||||
CompartmentID string `json:"compartmentId"` // 为空时建在租户根
|
||||
AvailabilityDomain string `json:"availabilityDomain"` // 为空时默认使用第一个可用域
|
||||
DisplayName string `json:"displayName" binding:"required"`
|
||||
Shape string `json:"shape" binding:"required"`
|
||||
Count int `json:"count"` // 批量创建数量,缺省 1,上限 10
|
||||
Ocpus float32 `json:"ocpus"`
|
||||
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||
ImageID string `json:"imageId"`
|
||||
BootVolumeID string `json:"bootVolumeId"`
|
||||
BootVolumeSizeGBs int64 `json:"bootVolumeSizeGBs"`
|
||||
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
|
||||
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
|
||||
AssignPublicIP bool `json:"assignPublicIp"`
|
||||
AssignIpv6 bool `json:"assignIpv6"`
|
||||
SSHPublicKey string `json:"sshPublicKey"`
|
||||
RootPassword string `json:"rootPassword"`
|
||||
GenerateRootPassword bool `json:"generateRootPassword"` // 服务端生成随机 root 密码并写入 TAG RootPassword
|
||||
UserData string `json:"userData"`
|
||||
}
|
||||
|
||||
type updateInstanceRequest struct {
|
||||
Region string `json:"region"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Shape string `json:"shape"` // 更换 shape,运行中实例会自动重启
|
||||
Ocpus float32 `json:"ocpus"`
|
||||
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||
}
|
||||
|
||||
type instanceActionRequest struct {
|
||||
Region string `json:"region"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
instances, err := h.svc.Instances(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instances)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createInstanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
count := req.Count
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
instances, failures, err := h.svc.CreateInstances(c.Request.Context(), id, oci.CreateInstanceInput{
|
||||
Region: req.Region,
|
||||
CompartmentID: req.CompartmentID,
|
||||
AvailabilityDomain: req.AvailabilityDomain,
|
||||
DisplayName: req.DisplayName,
|
||||
Shape: req.Shape,
|
||||
Ocpus: req.Ocpus,
|
||||
MemoryInGBs: req.MemoryInGBs,
|
||||
ImageID: req.ImageID,
|
||||
BootVolumeID: req.BootVolumeID,
|
||||
BootVolumeSizeGBs: req.BootVolumeSizeGBs,
|
||||
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
|
||||
SubnetID: req.SubnetID,
|
||||
AssignPublicIP: req.AssignPublicIP,
|
||||
AssignIpv6: req.AssignIpv6,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
RootPassword: req.RootPassword,
|
||||
GenerateRootPassword: req.GenerateRootPassword,
|
||||
UserData: req.UserData,
|
||||
}, count)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// 全部失败视为执行失败,部分成功仍返回 201 并附带逐台失败信息
|
||||
status := http.StatusCreated
|
||||
if len(instances) == 0 && len(failures) > 0 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
// nil 切片会序列化为 null,显式空数组免去前端判空
|
||||
if instances == nil {
|
||||
instances = []oci.Instance{}
|
||||
}
|
||||
if failures == nil {
|
||||
failures = []string{}
|
||||
}
|
||||
c.JSON(status, gin.H{"instances": instances, "errors": failures})
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
instance, err := h.svc.Instance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instance)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateInstanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instance, err := h.svc.UpdateInstance(c.Request.Context(), id, c.Param("instanceId"), oci.UpdateInstanceInput{
|
||||
Region: req.Region,
|
||||
DisplayName: req.DisplayName,
|
||||
Shape: req.Shape,
|
||||
Ocpus: req.Ocpus,
|
||||
MemoryInGBs: req.MemoryInGBs,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instance)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
preserve := c.Query("preserveBootVolume") == "true"
|
||||
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), preserve); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req instanceActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Action)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instance)
|
||||
}
|
||||
|
||||
// ---- 引导卷 ----
|
||||
|
||||
type updateBootVolumeRequest struct {
|
||||
Region string `json:"region"`
|
||||
DisplayName string `json:"displayName"`
|
||||
SizeInGBs int64 `json:"sizeInGBs"`
|
||||
VpusPerGB int64 `json:"vpusPerGB"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
volumes, err := h.svc.BootVolumes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, volumes)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
volume, err := h.svc.BootVolume(c.Request.Context(), id, c.Query("region"), c.Param("bootVolumeId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, volume)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateBootVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
volume, err := h.svc.UpdateBootVolume(c.Request.Context(), id, c.Param("bootVolumeId"), oci.UpdateBootVolumeInput{
|
||||
Region: req.Region,
|
||||
DisplayName: req.DisplayName,
|
||||
SizeInGBs: req.SizeInGBs,
|
||||
VpusPerGB: req.VpusPerGB,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, volume)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteBootVolume(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteBootVolume(c.Request.Context(), id, c.Query("region"), c.Param("bootVolumeId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// logEventHandler 处理回传事件查询与每租户回调地址管理(JWT 组内)。
|
||||
type logEventHandler struct {
|
||||
svc *service.LogEventService
|
||||
}
|
||||
|
||||
// getWebhook 查询配置的回调地址;未生成时 exists 为 false 且不含 secret。
|
||||
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info, exists, err := h.svc.SecretInfo(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusOK, gin.H{"exists": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"exists": true, "webhook": info})
|
||||
}
|
||||
|
||||
// ensureWebhook 生成(或幂等返回)配置的回传 secret 与回调路径。
|
||||
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info, err := h.svc.EnsureSecret(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// revokeWebhook 撤销配置的回传 secret,旧回调地址随即失效。
|
||||
func (h *logEventHandler) revokeWebhook(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.RevokeSecret(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// list 分页查询回传事件;cfgId 缺省为全部租户。
|
||||
func (h *logEventHandler) list(c *gin.Context) {
|
||||
cfgID, _ := strconv.ParseUint(c.Query("cfgId"), 10, 64)
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
|
||||
items, total, err := h.svc.List(c.Request.Context(), service.LogEventQuery{
|
||||
CfgID: uint(cfgID),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
// getRelay 查询 OCI 侧链路状态与关键事件清单。
|
||||
func (h *logEventHandler) getRelay(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view, err := h.svc.RelayStatus(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondRelayError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// setupRelay 一键建立回传链路(P1 引导创建);同步执行,前端以长超时等待。
|
||||
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view, err := h.svc.SetupRelay(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondRelayError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// teardownRelay 逆序销毁链路并撤销 secret。
|
||||
func (h *logEventHandler) teardownRelay(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.TeardownRelay(c.Request.Context(), id); err != nil {
|
||||
respondRelayError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// respondRelayError 在通用映射之上补充引导创建专属错误:依赖未配置映射 409。
|
||||
func respondRelayError(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrRelayNotConfigured) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
||||
const usernameKey = "username"
|
||||
|
||||
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
|
||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if !ok || token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
username, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
c.Set(usernameKey, username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
||||
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !isWriteMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
tee := &teeWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = tee
|
||||
c.Next()
|
||||
logs.Record(model.SystemLog{
|
||||
Username: c.GetString(usernameKey),
|
||||
Method: c.Request.Method,
|
||||
Path: requestPath(c),
|
||||
Status: c.Writer.Status(),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsgOf(c.Writer.Status(), tee.head),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// teeWriter 旁路捕获响应体前 512 字节,供失败留痕提取 error 文案;不改写响应。
|
||||
type teeWriter struct {
|
||||
gin.ResponseWriter
|
||||
head []byte
|
||||
}
|
||||
|
||||
func (w *teeWriter) Write(b []byte) (int, error) {
|
||||
if room := 512 - len(w.head); room > 0 {
|
||||
if len(b) < room {
|
||||
room = len(b)
|
||||
}
|
||||
w.head = append(w.head, b[:room]...)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// errMsgOf 从失败响应(>=400)的 JSON 中提取 error 字段;错误响应均由
|
||||
// respondError 系列生成、不含敏感信息,成功响应一律不记。
|
||||
func errMsgOf(status int, head []byte) string {
|
||||
if status < 400 || len(head) == 0 {
|
||||
return ""
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(head, &body) != nil || body.Error == "" {
|
||||
return ""
|
||||
}
|
||||
return truncateLogField(body.Error, 256)
|
||||
}
|
||||
|
||||
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。
|
||||
func truncateLogField(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
||||
// isWriteMethod 判断是否需要留痕的写方法。
|
||||
func isWriteMethod(m string) bool {
|
||||
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
|
||||
}
|
||||
|
||||
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
||||
// 未匹配到路由时退回原始路径;webhook 前缀的原始路径把 secret 段脱敏。
|
||||
func requestPath(c *gin.Context) string {
|
||||
if p := c.FullPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
return sanitizeLogPath(c.Request.URL.Path)
|
||||
}
|
||||
|
||||
// sanitizeLogPath 把回传 webhook 路径的最后一段(secret)替换为 ***;
|
||||
// 正常留痕走路由模板不经此处,这里是未匹配路由时的防御兜底。
|
||||
func sanitizeLogPath(path string) string {
|
||||
const prefix = "/api/v1/webhooks/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return path
|
||||
}
|
||||
if i := strings.LastIndex(path, "/"); i >= len(prefix) {
|
||||
return path[:i+1] + "***"
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
shapes, err := h.svc.Shapes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, shapes)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) images(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
images, err := h.svc.Images(c.Request.Context(), id, oci.ImagesQuery{
|
||||
Region: c.Query("region"),
|
||||
OperatingSystem: c.Query("operatingSystem"),
|
||||
Shape: c.Query("shape"),
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, images)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
image, err := h.svc.Image(c.Request.Context(), id, c.Query("region"), c.Param("imageId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, image)
|
||||
}
|
||||
|
||||
// ---- VCN ----
|
||||
|
||||
type createVCNRequest struct {
|
||||
Region string `json:"region"`
|
||||
CompartmentID string `json:"compartmentId"` // 为空时建在租户根
|
||||
DisplayName string `json:"displayName" binding:"required"`
|
||||
CidrBlock string `json:"cidrBlock" binding:"required"`
|
||||
DnsLabel string `json:"dnsLabel"`
|
||||
EnableIPv6 *bool `json:"enableIpv6"` // 缺省默认启用
|
||||
}
|
||||
|
||||
type renameRequest struct {
|
||||
Region string `json:"region"`
|
||||
DisplayName string `json:"displayName" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
vcns, err := h.svc.VCNs(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, vcns)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createVCNRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
vcn, err := h.svc.CreateVCN(c.Request.Context(), id, oci.CreateVCNInput{
|
||||
Region: req.Region,
|
||||
CompartmentID: req.CompartmentID,
|
||||
DisplayName: req.DisplayName,
|
||||
CidrBlock: req.CidrBlock,
|
||||
DnsLabel: req.DnsLabel,
|
||||
EnableIPv6: req.EnableIPv6 == nil || *req.EnableIPv6,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, vcn)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
vcn, err := h.svc.VCN(c.Request.Context(), id, c.Query("region"), c.Param("vcnId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, vcn)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req renameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
vcn, err := h.svc.UpdateVCN(c.Request.Context(), id, req.Region, c.Param("vcnId"), req.DisplayName)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, vcn)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteVCN(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteVCN(c.Request.Context(), id, c.Query("region"), c.Param("vcnId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type enableIPv6Request struct {
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req enableIPv6Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
steps, err := h.svc.EnableVCNIPv6(c.Request.Context(), id, req.Region, c.Param("vcnId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"steps": steps})
|
||||
}
|
||||
|
||||
// ---- Subnet ----
|
||||
|
||||
type createSubnetRequest struct {
|
||||
Region string `json:"region"`
|
||||
VcnID string `json:"vcnId" binding:"required"`
|
||||
DisplayName string `json:"displayName" binding:"required"`
|
||||
CidrBlock string `json:"cidrBlock" binding:"required"`
|
||||
DnsLabel string `json:"dnsLabel"`
|
||||
Ipv6CidrBlock string `json:"ipv6CidrBlock"`
|
||||
ProhibitPublicIP bool `json:"prohibitPublicIp"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subnets, err := h.svc.Subnets(c.Request.Context(), id, c.Query("region"), c.Query("vcnId"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subnets)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createSubnetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subnet, err := h.svc.CreateSubnet(c.Request.Context(), id, oci.CreateSubnetInput{
|
||||
Region: req.Region,
|
||||
VcnID: req.VcnID,
|
||||
DisplayName: req.DisplayName,
|
||||
CidrBlock: req.CidrBlock,
|
||||
DnsLabel: req.DnsLabel,
|
||||
Ipv6CidrBlock: req.Ipv6CidrBlock,
|
||||
ProhibitPublicIP: req.ProhibitPublicIP,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, subnet)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subnet, err := h.svc.Subnet(c.Request.Context(), id, c.Query("region"), c.Param("subnetId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subnet)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req renameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subnet, err := h.svc.UpdateSubnet(c.Request.Context(), id, req.Region, c.Param("subnetId"), req.DisplayName)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subnet)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteSubnet(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteSubnet(c.Request.Context(), id, c.Query("region"), c.Param("subnetId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Security List ----
|
||||
|
||||
type createSecurityListRequest struct {
|
||||
Region string `json:"region"`
|
||||
VcnID string `json:"vcnId" binding:"required"`
|
||||
DisplayName string `json:"displayName" binding:"required"`
|
||||
IngressRules []oci.SecurityRule `json:"ingressRules"`
|
||||
EgressRules []oci.SecurityRule `json:"egressRules"`
|
||||
}
|
||||
|
||||
type updateSecurityListRequest struct {
|
||||
Region string `json:"region"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IngressRules *[]oci.SecurityRule `json:"ingressRules"`
|
||||
EgressRules *[]oci.SecurityRule `json:"egressRules"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
lists, err := h.svc.SecurityLists(c.Request.Context(), id, c.Query("region"), c.Query("vcnId"), c.Query("compartmentId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createSecurityListRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list, err := h.svc.CreateSecurityList(c.Request.Context(), id, oci.CreateSecurityListInput{
|
||||
Region: req.Region,
|
||||
VcnID: req.VcnID,
|
||||
DisplayName: req.DisplayName,
|
||||
IngressRules: req.IngressRules,
|
||||
EgressRules: req.EgressRules,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, list)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := h.svc.SecurityList(c.Request.Context(), id, c.Query("region"), c.Param("securityListId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateSecurityListRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list, err := h.svc.UpdateSecurityList(c.Request.Context(), id, c.Param("securityListId"), oci.UpdateSecurityListInput{
|
||||
Region: req.Region,
|
||||
DisplayName: req.DisplayName,
|
||||
IngressRules: req.IngressRules,
|
||||
EgressRules: req.EgressRules,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteSecurityList(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteSecurityList(c.Request.Context(), id, c.Query("region"), c.Param("securityListId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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 直连
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) list(c *gin.Context) {
|
||||
items, err := h.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 关联
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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(不含租户根)。
|
||||
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 时实时刷新。
|
||||
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 返回筛选器用区间列表:未开多区间支持返回空数组。
|
||||
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
|
||||
}
|
||||
|
||||
func respondError(c *gin.Context, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
var svcErr common.ServiceError
|
||||
if errors.As(err, &svcErr) {
|
||||
respondOCIError(c, err, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
// 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 失效(前端收到即登出);
|
||||
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出
|
||||
if status == http.StatusUnauthorized {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
||||
func (h *ociConfigHandler) overview(c *gin.Context) {
|
||||
out, err := h.svc.Overview(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// proxyHandler 处理出站代理配置的增删改查。
|
||||
type proxyHandler struct {
|
||||
svc *service.ProxyService
|
||||
}
|
||||
|
||||
// list 返回全部代理(脱敏视图,含引用计数)。
|
||||
func (h *proxyHandler) list(c *gin.Context) {
|
||||
items, err := h.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// create 新建代理。
|
||||
func (h *proxyHandler) create(c *gin.Context) {
|
||||
var req service.ProxyInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
view, err := h.svc.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.respond(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, view)
|
||||
}
|
||||
|
||||
// update 更新代理;密码缺省沿用、空串清除。
|
||||
func (h *proxyHandler) update(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.ProxyInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
view, err := h.svc.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
h.respond(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// remove 删除代理;仍被租户引用时返回 409。
|
||||
func (h *proxyHandler) remove(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||
h.respond(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// importBatch 批量导入代理;逐行解析,返回成功与脱敏后的失败明细。
|
||||
func (h *proxyHandler) importBatch(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := h.svc.Import(c.Request.Context(), req.Text)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// probe 手动重测代理出口地区,同步返回最新视图。
|
||||
func (h *proxyHandler) probe(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view, err := h.svc.Probe(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.respond(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// respond 把代理业务错误映射到语义状态码。
|
||||
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrProxyInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrProxyInUse):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ipEvictInterval 是陈旧限速条目的回收周期。
|
||||
const ipEvictInterval = 10 * time.Minute
|
||||
|
||||
// ipRateLimiter 维护每 IP 令牌桶;速率与突发取安全设置快照,
|
||||
// 设置变更后既有桶就地调参;陈旧条目由后台 ticker 惰性回收。
|
||||
type ipRateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
limiters map[string]*ipEntry
|
||||
}
|
||||
|
||||
type ipEntry struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
func newIPRateLimiter() *ipRateLimiter {
|
||||
l := &ipRateLimiter{limiters: make(map[string]*ipEntry)}
|
||||
go l.evictLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
// get 返回 ip 对应的令牌桶,参数与当前配置不一致时就地调整。
|
||||
func (l *ipRateLimiter) get(ip string, rps, burst int) *rate.Limiter {
|
||||
l.mu.RLock()
|
||||
entry, ok := l.limiters[ip]
|
||||
l.mu.RUnlock()
|
||||
if !ok {
|
||||
l.mu.Lock()
|
||||
if entry, ok = l.limiters[ip]; !ok {
|
||||
entry = &ipEntry{limiter: rate.NewLimiter(rate.Limit(rps), burst)}
|
||||
l.limiters[ip] = entry
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
entry.lastSeen = time.Now()
|
||||
if entry.limiter.Limit() != rate.Limit(rps) || entry.limiter.Burst() != burst {
|
||||
entry.limiter.SetLimit(rate.Limit(rps))
|
||||
entry.limiter.SetBurst(burst)
|
||||
}
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
func (l *ipRateLimiter) evictLoop() {
|
||||
ticker := time.NewTicker(ipEvictInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
l.mu.Lock()
|
||||
cutoff := time.Now().Add(-ipEvictInterval)
|
||||
for ip, e := range l.limiters {
|
||||
if e.lastSeen.Before(cutoff) {
|
||||
delete(l.limiters, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// IPRateMiddleware 返回全局 IP 限速中间件;超限返回 429,参数随安全设置热更新。
|
||||
func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
||||
lm := newIPRateLimiter()
|
||||
return func(c *gin.Context) {
|
||||
sec := settings.SecurityCached()
|
||||
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ctxRealIPKey 是真实IP中间件写入 gin.Context 的键。
|
||||
const ctxRealIPKey = "realIP"
|
||||
|
||||
// RealIPMiddleware 按安全设置解析客户端真实 IP 写入上下文,
|
||||
// 全链路(IP 限速 / 登录守卫 / 系统日志留痕)统一消费;gin 内置
|
||||
// TrustedProxies 已禁用,信任边界完全由该配置决定。
|
||||
func RealIPMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(ctxRealIPKey, resolveRealIP(c.Request, settings.SecurityCached().RealIPHeader))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// requestIP 取中间件解析的真实 IP;未经中间件(单测直调 handler)时回退 RemoteAddr。
|
||||
func requestIP(c *gin.Context) string {
|
||||
if ip := c.GetString(ctxRealIPKey); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return remoteHost(c.Request)
|
||||
}
|
||||
|
||||
// resolveRealIP 依配置头解析:X-Forwarded-For 取链尾(直连反代的对端,
|
||||
// 反代会把对端追加到链尾,链首可被客户端伪造);X-Real-IP 等单值头直取;
|
||||
// 头缺失或未配置(直连部署)回退 RemoteAddr。
|
||||
func resolveRealIP(r *http.Request, header string) string {
|
||||
switch header {
|
||||
case "":
|
||||
case "X-Forwarded-For":
|
||||
if v := r.Header.Get(header); v != "" {
|
||||
parts := strings.Split(v, ",")
|
||||
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
default:
|
||||
if ip := strings.TrimSpace(r.Header.Get(header)); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return remoteHost(r)
|
||||
}
|
||||
|
||||
// remoteHost 取 RemoteAddr 的 host 部分。
|
||||
func remoteHost(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveRealIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string // 配置的真实IP请求头
|
||||
reqHdr map[string]string
|
||||
remote string
|
||||
want string
|
||||
}{
|
||||
{name: "未配置头取 RemoteAddr", header: "", reqHdr: map[string]string{"X-Forwarded-For": "1.2.3.4"}, remote: "127.0.0.1:5000", want: "127.0.0.1"},
|
||||
{name: "XFF 单值", header: "X-Forwarded-For", reqHdr: map[string]string{"X-Forwarded-For": "203.0.113.7"}, remote: "127.0.0.1:5000", want: "203.0.113.7"},
|
||||
{name: "XFF 链取尾防伪造", header: "X-Forwarded-For", reqHdr: map[string]string{"X-Forwarded-For": "6.6.6.6, 203.0.113.7"}, remote: "127.0.0.1:5000", want: "203.0.113.7"},
|
||||
{name: "XFF 缺失回退 RemoteAddr", header: "X-Forwarded-For", reqHdr: nil, remote: "192.0.2.9:1234", want: "192.0.2.9"},
|
||||
{name: "X-Real-IP 单值直取", header: "X-Real-IP", reqHdr: map[string]string{"X-Real-IP": "203.0.113.8"}, remote: "127.0.0.1:5000", want: "203.0.113.8"},
|
||||
{name: "CF-Connecting-IP", header: "CF-Connecting-IP", reqHdr: map[string]string{"CF-Connecting-IP": "203.0.113.9", "X-Forwarded-For": "6.6.6.6"}, remote: "127.0.0.1:5000", want: "203.0.113.9"},
|
||||
{name: "RemoteAddr 无端口原样返回", header: "", reqHdr: nil, remote: "unix", want: "unix"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = tt.remote
|
||||
for k, v := range tt.reqHdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
if got := resolveRealIP(r, tt.header); got != tt.want {
|
||||
t.Errorf("resolveRealIP = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// listRegions 返回本地维护的完整区域表(含友好名称),不发起云端请求。
|
||||
func listRegions(c *gin.Context) {
|
||||
regions, err := oci.AllRegions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, regions)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subs, err := h.svc.RegionSubscriptions(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
type subscribeRegionRequest struct {
|
||||
RegionKey string `json:"regionKey" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req subscribeRegionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subs, err := h.svc.SubscribeRegion(c.Request.Context(), id, req.RegionKey)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) limits(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scopeType := strings.ToUpper(c.Query("scopeType"))
|
||||
if scopeType != "" && scopeType != "GLOBAL" && scopeType != "REGION" && scopeType != "AD" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scopeType must be GLOBAL / REGION / AD"})
|
||||
return
|
||||
}
|
||||
q := oci.LimitsQuery{
|
||||
Region: c.Query("region"),
|
||||
Service: c.DefaultQuery("service", "compute"),
|
||||
Name: c.Query("name"),
|
||||
ScopeType: scopeType,
|
||||
AvailabilityDomain: c.Query("availabilityDomain"),
|
||||
NonZero: c.Query("nonZero") == "true",
|
||||
WithAvailability: c.Query("withAvailability") == "true",
|
||||
}
|
||||
values, err := h.svc.Limits(c.Request.Context(), id, q)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, values)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// NewRouter 组装全部 HTTP 路由:/auth/login 公开,业务路由要求 JWT。
|
||||
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
|
||||
r := gin.New()
|
||||
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
// webhook 注册在全局 Logger 之前:访问日志不落含 secret 的路径;
|
||||
// 不挂系统日志中间件——投递高频且已在回传日志留痕,避免刷屏
|
||||
wh := &webhookHandler{events: logEvents}
|
||||
r.POST("/api/v1/webhooks/oci-logs/:secret",
|
||||
gin.Recovery(), RealIPMiddleware(settings), wh.handle)
|
||||
|
||||
r.Use(gin.Logger(), RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||
v1.POST("/auth/login", ah.login)
|
||||
|
||||
// 外部身份登录(公开):provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||
|
||||
// 控制台数据面:浏览器 WebSocket 无法带自定义头,token 经 query 在 handler 内校验
|
||||
ch := &consoleHandler{svc: console, auth: auth}
|
||||
v1.GET("/console-sessions/:sessionId/ws", ch.ws)
|
||||
|
||||
// AI 网关对外端点:独立密钥鉴权(Bearer / x-api-key),不挂 JWT 与系统日志
|
||||
// (高频调用自有 AiCallLog);挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||
aih := &aiGatewayHandler{gw: aiGateway}
|
||||
ai := r.Group("/ai/v1", aih.auth)
|
||||
ai.POST("/chat/completions", aih.chatCompletions)
|
||||
ai.POST("/responses", aih.responses)
|
||||
ai.POST("/messages", aih.messages)
|
||||
ai.POST("/embeddings", aih.embeddings)
|
||||
ai.GET("/models", aih.listModels)
|
||||
|
||||
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
||||
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||
secured.POST("/auth/logout", ah.logout)
|
||||
secured.GET("/regions", listRegions)
|
||||
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/console-sessions", ch.create)
|
||||
secured.DELETE("/console-sessions/:sessionId", ch.remove)
|
||||
|
||||
st := &settingsHandler{svc: settings, notifier: notifier}
|
||||
secured.GET("/about", about)
|
||||
secured.GET("/settings/telegram", st.getTelegram)
|
||||
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||
secured.POST("/settings/telegram/test", st.testTelegram)
|
||||
secured.GET("/settings/notify-events", st.getNotifyEvents)
|
||||
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
|
||||
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
|
||||
secured.PUT("/settings/notify-templates/:kind", st.updateNotifyTemplate)
|
||||
secured.POST("/settings/notify-templates/:kind/test", st.testNotifyTemplate)
|
||||
secured.GET("/settings/task", st.getTaskSettings)
|
||||
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||
secured.GET("/settings/security", st.getSecurity)
|
||||
secured.PUT("/settings/security", st.updateSecurity)
|
||||
|
||||
// 两步验证与外部身份管理(JWT 组内)
|
||||
secured.GET("/auth/totp", ax.totpStatus)
|
||||
secured.POST("/auth/totp/setup", ax.totpSetup)
|
||||
secured.POST("/auth/totp/activate", ax.totpActivate)
|
||||
secured.POST("/auth/totp/disable", ax.totpDisable)
|
||||
secured.GET("/auth/credentials", ax.getCredentials)
|
||||
secured.PUT("/auth/credentials", ax.updateCredentials)
|
||||
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
|
||||
secured.GET("/auth/identities", ax.identities)
|
||||
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||
|
||||
px := &proxyHandler{svc: proxies}
|
||||
secured.GET("/proxies", px.list)
|
||||
secured.POST("/proxies", px.create)
|
||||
secured.POST("/proxies/import", px.importBatch)
|
||||
secured.PUT("/proxies/:id", px.update)
|
||||
secured.DELETE("/proxies/:id", px.remove)
|
||||
secured.POST("/proxies/:id/probe", px.probe)
|
||||
|
||||
le := &logEventHandler{svc: logEvents}
|
||||
secured.GET("/log-events", le.list)
|
||||
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
|
||||
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
|
||||
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
|
||||
secured.GET("/oci-configs/:id/log-relay", le.getRelay)
|
||||
secured.POST("/oci-configs/:id/log-relay", le.setupRelay)
|
||||
secured.DELETE("/oci-configs/:id/log-relay", le.teardownRelay)
|
||||
|
||||
t := &taskHandler{svc: tasks}
|
||||
secured.POST("/tasks", t.create)
|
||||
secured.GET("/tasks", t.list)
|
||||
secured.GET("/tasks/:id", t.get)
|
||||
secured.PUT("/tasks/:id", t.update)
|
||||
secured.DELETE("/tasks/:id", t.remove)
|
||||
secured.GET("/tasks/:id/logs", t.logs)
|
||||
secured.POST("/tasks/:id/run", t.run)
|
||||
|
||||
h := &ociConfigHandler{svc: ociConfigs}
|
||||
secured.GET("/overview", h.overview)
|
||||
secured.POST("/oci-configs", h.create)
|
||||
secured.GET("/oci-configs", h.list)
|
||||
secured.GET("/oci-configs/:id", h.get)
|
||||
secured.PUT("/oci-configs/:id", h.update)
|
||||
secured.POST("/oci-configs/:id/verify", h.verify)
|
||||
secured.DELETE("/oci-configs/:id", h.remove)
|
||||
secured.GET("/oci-configs/:id/compartments", h.compartments)
|
||||
secured.GET("/oci-configs/:id/cached-regions", h.cachedRegions)
|
||||
secured.GET("/oci-configs/:id/cached-compartments", h.cachedCompartments)
|
||||
secured.GET("/oci-configs/:id/region-subscriptions", h.regionSubscriptions)
|
||||
secured.POST("/oci-configs/:id/region-subscriptions", h.subscribeRegion)
|
||||
secured.GET("/oci-configs/:id/limits", h.limits)
|
||||
secured.GET("/oci-configs/:id/limits/services", h.limitServices)
|
||||
secured.GET("/oci-configs/:id/subscriptions", h.subscriptions)
|
||||
secured.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
|
||||
secured.GET("/oci-configs/:id/shapes", h.shapes)
|
||||
secured.GET("/oci-configs/:id/images", h.images)
|
||||
secured.GET("/oci-configs/:id/images/:imageId", h.getImage)
|
||||
secured.GET("/oci-configs/:id/vcns", h.listVCNs)
|
||||
secured.POST("/oci-configs/:id/vcns", h.createVCN)
|
||||
secured.GET("/oci-configs/:id/vcns/:vcnId", h.getVCN)
|
||||
secured.PUT("/oci-configs/:id/vcns/:vcnId", h.updateVCN)
|
||||
secured.DELETE("/oci-configs/:id/vcns/:vcnId", h.deleteVCN)
|
||||
secured.POST("/oci-configs/:id/vcns/:vcnId/enable-ipv6", h.enableVCNIPv6)
|
||||
secured.GET("/oci-configs/:id/subnets", h.listSubnets)
|
||||
secured.POST("/oci-configs/:id/subnets", h.createSubnet)
|
||||
secured.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
|
||||
secured.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
|
||||
secured.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
|
||||
secured.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
|
||||
secured.POST("/oci-configs/:id/security-lists", h.createSecurityList)
|
||||
secured.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
|
||||
secured.PUT("/oci-configs/:id/security-lists/:securityListId", h.updateSecurityList)
|
||||
secured.DELETE("/oci-configs/:id/security-lists/:securityListId", h.deleteSecurityList)
|
||||
secured.GET("/oci-configs/:id/availability-domains", h.availabilityDomains)
|
||||
secured.GET("/oci-configs/:id/instances", h.listInstances)
|
||||
secured.POST("/oci-configs/:id/instances", h.createInstance)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId", h.getInstance)
|
||||
secured.PUT("/oci-configs/:id/instances/:instanceId", h.updateInstance)
|
||||
secured.DELETE("/oci-configs/:id/instances/:instanceId", h.terminateInstance)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/action", h.instanceAction)
|
||||
secured.GET("/oci-configs/:id/boot-volumes", h.listBootVolumes)
|
||||
secured.GET("/oci-configs/:id/boot-volumes/:bootVolumeId", h.getBootVolume)
|
||||
secured.PUT("/oci-configs/:id/boot-volumes/:bootVolumeId", h.updateBootVolume)
|
||||
secured.DELETE("/oci-configs/:id/boot-volumes/:bootVolumeId", h.deleteBootVolume)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/change-public-ip", h.changePublicIP)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/ipv6-addresses", h.addInstanceIpv6)
|
||||
secured.DELETE("/oci-configs/:id/instances/:instanceId/ipv6-addresses", h.deleteInstanceIpv6)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/console-connections", h.createConsoleConnection)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId/console-connections", h.listConsoleConnections)
|
||||
secured.DELETE("/oci-configs/:id/console-connections/:connectionId", h.deleteConsoleConnection)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId/vnics", h.listInstanceVnics)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
|
||||
secured.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
|
||||
secured.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId/boot-volume-attachments", h.listBootVolumeAttachments)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/boot-volume-attachments", h.attachBootVolume)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/replace-boot-volume", h.replaceBootVolume)
|
||||
secured.DELETE("/oci-configs/:id/boot-volume-attachments/:attachmentId", h.detachBootVolume)
|
||||
secured.GET("/oci-configs/:id/volumes", h.listBlockVolumes)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId/volume-attachments", h.listVolumeAttachments)
|
||||
secured.POST("/oci-configs/:id/instances/:instanceId/volume-attachments", h.attachVolume)
|
||||
secured.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume)
|
||||
secured.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic)
|
||||
secured.GET("/oci-configs/:id/costs", h.costs)
|
||||
// AI 网关面板管理:密钥 / 渠道(号池)/ 聚合模型 / 调用日志
|
||||
aiadmin := &aiAdminHandler{gw: aiGateway}
|
||||
secured.GET("/ai-keys", aiadmin.listKeys)
|
||||
secured.POST("/ai-keys", aiadmin.createKey)
|
||||
secured.PUT("/ai-keys/:id", aiadmin.updateKey)
|
||||
secured.PUT("/ai-keys/:id/content-log", aiadmin.updateKeyContentLog)
|
||||
secured.DELETE("/ai-keys/:id", aiadmin.deleteKey)
|
||||
secured.GET("/ai-channels", aiadmin.listChannels)
|
||||
secured.POST("/ai-channels", aiadmin.createChannel)
|
||||
secured.PUT("/ai-channels/:id", aiadmin.updateChannel)
|
||||
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-logs", aiadmin.listLogs)
|
||||
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
||||
secured.GET("/oci-configs/:id/audit-events", h.getAuditEvents)
|
||||
secured.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
|
||||
secured.GET("/oci-configs/:id/users", h.listTenantUsers)
|
||||
secured.POST("/oci-configs/:id/users", h.createTenantUser)
|
||||
secured.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
|
||||
secured.PUT("/oci-configs/:id/users/:userId", h.updateTenantUser)
|
||||
secured.DELETE("/oci-configs/:id/users/:userId", h.deleteTenantUser)
|
||||
secured.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
|
||||
secured.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
|
||||
secured.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
||||
secured.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||
secured.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||
secured.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
||||
secured.PUT("/oci-configs/:id/password-policies/:policyId", h.updatePasswordPolicy)
|
||||
secured.GET("/oci-configs/:id/identity-settings", h.getIdentitySetting)
|
||||
secured.PUT("/oci-configs/:id/identity-settings", h.updateIdentitySetting)
|
||||
secured.GET("/oci-configs/:id/identity-providers", h.listIdentityProviders)
|
||||
secured.POST("/oci-configs/:id/identity-providers", h.createIdentityProvider)
|
||||
secured.POST("/oci-configs/:id/identity-providers/:idpId/activate", h.activateIdentityProvider)
|
||||
secured.DELETE("/oci-configs/:id/identity-providers/:idpId", h.deleteIdentityProvider)
|
||||
secured.GET("/oci-configs/:id/saml-metadata", h.downloadSamlMetadata)
|
||||
secured.GET("/oci-configs/:id/sign-on-rules", h.listSignOnRules)
|
||||
secured.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||
secured.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"oci-portal/internal/crypto"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// nullClient 是 oci.Client 的空实现,路由测试不触发任何云调用。
|
||||
// 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。
|
||||
type nullClient struct{ oci.Client }
|
||||
|
||||
func (nullClient) ValidateKey(context.Context, oci.Credentials) (oci.TenancyInfo, error) {
|
||||
return oci.TenancyInfo{}, nil
|
||||
}
|
||||
|
||||
func (nullClient) FetchAccountProfile(context.Context, oci.Credentials) (oci.AccountProfile, error) {
|
||||
return oci.AccountProfile{}, nil
|
||||
}
|
||||
|
||||
func (nullClient) ListRegionSubscriptions(context.Context, oci.Credentials) ([]oci.RegionSubscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nullClient) SubscribeRegion(context.Context, oci.Credentials, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nullClient) ListLimits(context.Context, oci.Credentials, oci.LimitsQuery) ([]oci.LimitValue, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nullClient) ListLimitServices(context.Context, oci.Credentials, string) ([]oci.LimitService, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nullClient) ListSubscriptions(context.Context, oci.Credentials) ([]oci.SubscriptionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci.SubscriptionDetail, error) {
|
||||
return oci.SubscriptionDetail{}, nil
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
// :memory: 库每个连接彼此独立,异步日志写入会触发池内新连接而丢表,锁单连接
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.User{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
cipher, err := crypto.NewCipher("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
auth := service.NewAuthService(db, "test-secret")
|
||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||
t.Fatalf("ensure admin: %v", err)
|
||||
}
|
||||
ociConfigs := service.NewOciConfigService(db, cipher, nullClient{})
|
||||
settings := service.NewSettingService(db, cipher)
|
||||
notifier := service.NewNotifier(settings)
|
||||
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||
systemLogs := service.NewSystemLogService(db)
|
||||
logEvents := service.NewLogEventService(db)
|
||||
oauth := service.NewOAuthService(db, settings, auth)
|
||||
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
||||
return r, auth, systemLogs
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestLoginEndpoint(t *testing.T) {
|
||||
r, _, _ := newTestRouter(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "正确凭据", body: `{"username":"admin","password":"pass123"}`, wantStatus: http.StatusOK},
|
||||
{name: "密码错误", body: `{"username":"admin","password":"wrong"}`, wantStatus: http.StatusUnauthorized},
|
||||
{name: "缺少字段", body: `{"username":"admin"}`, wantStatus: http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", tt.body)
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d, body %s", w.Code, tt.wantStatus, w.Body.String())
|
||||
}
|
||||
if tt.wantStatus == http.StatusOK && !strings.Contains(w.Body.String(), "token") {
|
||||
t.Errorf("body = %s, want token field", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecuredRoutesRequireToken(t *testing.T) {
|
||||
r, auth, _ := newTestRouter(t)
|
||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "无令牌", token: "", wantStatus: http.StatusUnauthorized},
|
||||
{name: "伪造令牌", token: "forged.token.value", wantStatus: http.StatusUnauthorized},
|
||||
{name: "有效令牌", token: token, wantStatus: http.StatusOK},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := doRequest(t, r, http.MethodGet, "/api/v1/oci-configs", tt.token, "")
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d, body %s", w.Code, tt.wantStatus, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemLogsEndpoint(t *testing.T) {
|
||||
r, auth, logs := newTestRouter(t)
|
||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
// 经 HTTP 触发一次登录失败:埋点应异步落一条 401 记录
|
||||
doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"wrong"}`)
|
||||
logs.Wait()
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantTotal string
|
||||
}{
|
||||
{name: "无关键字返回全部", query: "", wantTotal: `"total":1`},
|
||||
{name: "关键字命中登录路径", query: "?keyword=login", wantTotal: `"total":1`},
|
||||
{name: "关键字不命中返回空", query: "?keyword=no-such", wantTotal: `"total":0`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := doRequest(t, r, http.MethodGet, "/api/v1/system-logs"+tt.query, token, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "items") || !strings.Contains(body, tt.wantTotal) {
|
||||
t.Errorf("body = %s, want items and %s", body, tt.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
||||
type settingsHandler struct {
|
||||
svc *service.SettingService
|
||||
notifier *service.Notifier
|
||||
}
|
||||
|
||||
// getTelegram 返回脱敏后的 Telegram 配置,绝不回 token 明文。
|
||||
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
||||
view, err := h.svc.TelegramView(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateTelegramRequest 是保存 Telegram 配置的请求体;
|
||||
// botToken 缺省表示沿用已存 token,空串表示清除。
|
||||
type updateTelegramRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken *string `json:"botToken"`
|
||||
ChatID string `json:"chatId"`
|
||||
}
|
||||
|
||||
// updateTelegram 保存配置并返回最新脱敏视图。
|
||||
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
||||
var req updateTelegramRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.svc.UpdateTelegram(c.Request.Context(), service.UpdateTelegramInput{
|
||||
Enabled: req.Enabled,
|
||||
BotToken: req.BotToken,
|
||||
ChatID: req.ChatID,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.getTelegram(c)
|
||||
}
|
||||
|
||||
// testTelegram 用当前已保存配置同步发送一条测试消息。
|
||||
func (h *settingsHandler) testTelegram(c *gin.Context) {
|
||||
if err := h.notifier.Test(c.Request.Context()); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
|
||||
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
||||
view, err := h.svc.NotifyEvents(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateNotifyEvents 全量保存五个事件开关并返回最新值;
|
||||
// 请求体为全量语义,缺字段按 JSON 零值 false 处理。
|
||||
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
||||
var req service.NotifyEventsView
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateNotifyEvents(c.Request.Context(), req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.getNotifyEvents(c)
|
||||
}
|
||||
|
||||
// listNotifyTemplates 返回全部通知模板(默认模板 + 自定义现值 + 可用变量)。
|
||||
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
||||
items, err := h.svc.NotifyTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// updateNotifyTemplate 保存单个通知模板;空模板清除自定义(恢复默认)。
|
||||
func (h *settingsHandler) updateNotifyTemplate(c *gin.Context) {
|
||||
var req struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateNotifyTemplate(c.Request.Context(), c.Param("kind"), req.Template); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testNotifyTemplate 用示例变量渲染模板并同步发送测试消息;
|
||||
// template 非空时按编辑中内容渲染(不落库)。
|
||||
func (h *settingsHandler) testNotifyTemplate(c *gin.Context) {
|
||||
var req struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.notifier.SendTemplateTest(c.Request.Context(), c.Param("kind"), req.Template); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getTaskSettings 返回任务行为设置(抢机熔断阈值),阈值未保存过时为默认值。
|
||||
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
||||
view, err := h.svc.TaskSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateTaskSettings 保存任务行为设置并返回最新值;阈值越界返回 400。
|
||||
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
||||
var req service.TaskSettingsView
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateTaskSettings(c.Request.Context(), req); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidAuthFailLimit) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.getTaskSettings(c)
|
||||
}
|
||||
|
||||
// getSecurity 返回安全设置(WAF 参数 / 真实IP请求头 / 面板地址),未保存过时为默认值。
|
||||
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||
view, err := h.svc.Security(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||
var req service.SecuritySettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateSecurity(c.Request.Context(), req); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSecurity) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.getSecurity(c)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subs, err := h.svc.Subscriptions(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.svc.SubscriptionDetail(c.Request.Context(), id, c.Param("subscriptionId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
services, err := h.svc.LimitServices(c.Request.Context(), id, c.Query("region"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, services)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// systemLogHandler 处理面板系统操作日志的查询请求。
|
||||
type systemLogHandler struct {
|
||||
svc *service.SystemLogService
|
||||
}
|
||||
|
||||
// list 分页倒序返回系统日志,支持路径 / 用户名关键字模糊过滤。
|
||||
func (h *systemLogHandler) list(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
items, total, err := h.svc.List(c.Request.Context(), service.SystemLogQuery{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Keyword: c.Query("keyword"),
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// taskHandler 处理后台任务(定时测活、抢机)相关请求。
|
||||
type taskHandler struct {
|
||||
svc *service.TaskService
|
||||
}
|
||||
|
||||
type createTaskRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
CronExpr string `json:"cronExpr" binding:"required"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type updateTaskRequest struct {
|
||||
Name *string `json:"name"`
|
||||
CronExpr *string `json:"cronExpr"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *taskHandler) create(c *gin.Context) {
|
||||
var req createTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
task, err := h.svc.CreateTask(c.Request.Context(), service.CreateTaskInput{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
CronExpr: req.CronExpr,
|
||||
Payload: req.Payload,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, task)
|
||||
}
|
||||
|
||||
func (h *taskHandler) list(c *gin.Context) {
|
||||
tasks, err := h.svc.ListTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
func (h *taskHandler) get(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
task, err := h.svc.GetTask(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
func (h *taskHandler) update(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
task, err := h.svc.UpdateTask(c.Request.Context(), id, service.UpdateTaskInput{
|
||||
Name: req.Name,
|
||||
CronExpr: req.CronExpr,
|
||||
Payload: req.Payload,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
func (h *taskHandler) remove(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteTask(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *taskHandler) logs(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
logs, err := h.svc.TaskLogs(c.Request.Context(), id, limit)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
|
||||
func (h *taskHandler) run(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entry)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ---- 流量与成本 ----
|
||||
|
||||
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||
traffic, err := h.svc.InstanceTraffic(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), days)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, traffic)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := oci.CostQuery{
|
||||
Granularity: c.Query("granularity"),
|
||||
QueryType: c.Query("queryType"),
|
||||
GroupBy: c.Query("groupBy"),
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.Query("startTime")); err == nil {
|
||||
q.StartTime = t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.Query("endTime")); err == nil {
|
||||
q.EndTime = t
|
||||
}
|
||||
items, err := h.svc.Costs(c.Request.Context(), id, q)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// ---- 租户审计日志 ----
|
||||
|
||||
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24);
|
||||
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400。
|
||||
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||
q := service.AuditQuery{
|
||||
Region: c.Query("region"), Hours: hours,
|
||||
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
|
||||
}
|
||||
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||
if errors.Is(err, service.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// getAuditEventDetail 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
||||
// 过期走事件时间小窗重查;仍不可得返回 404,前端降级展示精简字段。
|
||||
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventID := c.Query("eventId")
|
||||
eventTime, terr := time.Parse(time.RFC3339, c.Query("eventTime"))
|
||||
if eventID == "" || terr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "eventId 与 eventTime(RFC3339)必填"})
|
||||
return
|
||||
}
|
||||
raw, err := h.svc.AuditEventDetail(c.Request.Context(), id, c.Query("region"), eventID, eventTime)
|
||||
if errors.Is(err, service.ErrAuditEventGone) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"raw": raw})
|
||||
}
|
||||
|
||||
// ---- 租户用户管理 ----
|
||||
|
||||
type createTenantUserRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Email string `json:"email"`
|
||||
GivenName string `json:"givenName" binding:"required"`
|
||||
FamilyName string `json:"familyName" binding:"required"`
|
||||
// 同时勾选时先授身份域管理员,再加入管理员组
|
||||
GrantDomainAdmin bool `json:"grantDomainAdmin"`
|
||||
AddToAdminGroup bool `json:"addToAdminGroup"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := h.svc.TenantUsers(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createTenantUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, oci.CreateTenantUserInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Email: req.Email,
|
||||
GivenName: req.GivenName,
|
||||
FamilyName: req.FamilyName,
|
||||
GrantDomainAdmin: req.GrantDomainAdmin,
|
||||
AddToAdminGroup: req.AddToAdminGroup,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
// updateTenantUserRequest 的字段全部可缺省:nil 不修改,传空串表示清空。
|
||||
// 管理员字段为三态:true 授予/加入,false 撤销/移出,缺省不动。
|
||||
type updateTenantUserRequest struct {
|
||||
Description *string `json:"description"`
|
||||
Email *string `json:"email"`
|
||||
GivenName *string `json:"givenName"`
|
||||
FamilyName *string `json:"familyName"`
|
||||
GrantDomainAdmin *bool `json:"grantDomainAdmin"`
|
||||
AddToAdminGroup *bool `json:"addToAdminGroup"`
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateTenantUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Param("userId"), oci.UpdateTenantUserInput{
|
||||
Description: req.Description,
|
||||
Email: req.Email,
|
||||
GivenName: req.GivenName,
|
||||
FamilyName: req.FamilyName,
|
||||
GrantDomainAdmin: req.GrantDomainAdmin,
|
||||
AddToAdminGroup: req.AddToAdminGroup,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Param("userId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"password": password})
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deletedTotpDevices": deleted})
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
includeCurrent := c.Query("includeCurrent") == "true"
|
||||
deleted, err := h.svc.DeleteTenantUserApiKeys(c.Request.Context(), id, c.Param("userId"), includeCurrent)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
||||
}
|
||||
|
||||
// ---- 域通知收件人与密码策略 ----
|
||||
|
||||
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, recipients)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Recipients []string `json:"recipients"` // 空列表表示关闭 test mode 恢复默认发送
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, req.Recipients)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, recipients)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, policies)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PasswordExpiresAfter *int `json:"passwordExpiresAfter"`
|
||||
MinLength *int `json:"minLength"`
|
||||
NumPasswordsInHistory *int `json:"numPasswordsInHistory"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Param("policyId"), oci.UpdatePasswordPolicyInput{
|
||||
PasswordExpiresAfter: req.PasswordExpiresAfter,
|
||||
MinLength: req.MinLength,
|
||||
NumPasswordsInHistory: req.NumPasswordsInHistory,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, policy)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setting, err := h.svc.IdentitySetting(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
|
||||
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PrimaryEmailRequired *bool `json:"primaryEmailRequired" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, *req.PrimaryEmailRequired)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// consoleHandler 提供网页控制台(串行 / VNC):创建会话 + WebSocket 数据面。
|
||||
type consoleHandler struct {
|
||||
svc *service.ConsoleService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
type createConsoleSessionRequest struct {
|
||||
Type string `json:"type"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// create 创建控制台会话(内部清理残留连接并创建 OCI 控制台连接,约 30-90 秒)。
|
||||
func (h *consoleHandler) create(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createConsoleSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sess, err := h.svc.CreateSession(c.Request.Context(), id, c.Param("instanceId"), req.Region, req.Type)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"sessionId": sess.ID, "type": sess.Type})
|
||||
}
|
||||
|
||||
// remove 结束会话并删除云端控制台连接。
|
||||
func (h *consoleHandler) remove(c *gin.Context) {
|
||||
h.svc.CloseSession(c.Param("sessionId"))
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// wsUpgrader 同源校验交给 token(浏览器 WebSocket 无法自定义头,token 走 query)。
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 32 * 1024,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ws 是控制台数据面:校验 token 后建立两跳 SSH 隧道,按会话类型分派数据泵。
|
||||
func (h *consoleHandler) ws(c *gin.Context) {
|
||||
if _, err := h.auth.ParseToken(c.Query("token")); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
sess := h.svc.Get(c.Param("sessionId"))
|
||||
if sess == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "console session not found or expired"})
|
||||
return
|
||||
}
|
||||
wsConn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer wsConn.Close()
|
||||
sess.MarkUse(true)
|
||||
defer sess.MarkUse(false)
|
||||
if sess.Type == service.ConsoleTypeSerial {
|
||||
h.serveSerial(c, sess, wsConn)
|
||||
return
|
||||
}
|
||||
h.serveVnc(c, sess, wsConn)
|
||||
}
|
||||
|
||||
func (h *consoleHandler) serveVnc(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) {
|
||||
vnc, err := sess.DialVNC(c.Request.Context())
|
||||
if err != nil {
|
||||
closeWs(wsConn, "tunnel: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer vnc.Close()
|
||||
proxyVnc(wsConn, vnc)
|
||||
}
|
||||
|
||||
// serialQuery 读取 ws 建连时的初始终端尺寸(缺省 80x24)。
|
||||
func serialQuery(c *gin.Context, key string, def int) int {
|
||||
v := 0
|
||||
for _, ch := range c.Query(key) {
|
||||
if ch < '0' || ch > '9' {
|
||||
return def
|
||||
}
|
||||
v = v*10 + int(ch-'0')
|
||||
}
|
||||
if v < 1 || v > 1000 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *consoleHandler) serveSerial(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) {
|
||||
term, err := sess.OpenSerial(c.Request.Context(),
|
||||
serialQuery(c, "rows", 24), serialQuery(c, "cols", 80))
|
||||
if err != nil {
|
||||
closeWs(wsConn, "tunnel: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer term.Close()
|
||||
proxySerial(wsConn, term)
|
||||
}
|
||||
|
||||
func closeWs(wsConn *websocket.Conn, reason string) {
|
||||
// CloseMessage 载荷上限 125 字节,超长原因截断避免发送失败
|
||||
if len(reason) > 120 {
|
||||
reason = reason[:120]
|
||||
}
|
||||
_ = wsConn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason))
|
||||
}
|
||||
|
||||
// serialControl 是串行会话的文本帧控制消息(二进制帧为终端原始字节)。
|
||||
type serialControl struct {
|
||||
Type string `json:"type"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
// proxySerial 串行数据泵:二进制帧 ↔ 终端字节流,文本帧解析 resize 控制消息;
|
||||
// ws 写只发生在本 goroutine,无并发写。
|
||||
func proxySerial(wsConn *websocket.Conn, term *service.SerialTerminal) {
|
||||
go func() {
|
||||
defer term.Close()
|
||||
for {
|
||||
t, data, err := wsConn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch t {
|
||||
case websocket.BinaryMessage:
|
||||
if _, err := term.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
case websocket.TextMessage:
|
||||
var ctl serialControl
|
||||
if json.Unmarshal(data, &ctl) == nil && ctl.Type == "resize" {
|
||||
_ = term.Resize(ctl.Rows, ctl.Cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
pumpToWs(wsConn, term)
|
||||
}
|
||||
|
||||
// proxyVnc VNC 数据泵:双向透传 RFB 字节流直到任一侧断开。
|
||||
func proxyVnc(wsConn *websocket.Conn, vnc io.ReadWriteCloser) {
|
||||
go func() {
|
||||
defer vnc.Close()
|
||||
for {
|
||||
t, data, err := wsConn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if t == websocket.BinaryMessage || t == websocket.TextMessage {
|
||||
if _, err := vnc.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
pumpToWs(wsConn, vnc)
|
||||
}
|
||||
|
||||
// pumpToWs 把数据源持续泵入 ws 二进制帧,直到任一侧断开。
|
||||
func pumpToWs(wsConn *websocket.Conn, src io.Reader) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
if werr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ONS 消息头(方案A §2.2/§2.3,官方 X-OCI-NS-* 字段)。
|
||||
const (
|
||||
onsHeaderMessageType = "X-OCI-NS-MessageType"
|
||||
onsHeaderMessageID = "X-OCI-NS-MessageId"
|
||||
onsHeaderTimestamp = "X-OCI-NS-Timestamp"
|
||||
onsHeaderConfirmURL = "X-OCI-NS-ConfirmationURL"
|
||||
)
|
||||
|
||||
// webhook 同步路径的防御参数:body 上限高于 ONS 链路 128KB 留余量,
|
||||
// 时间戳偏差超窗拒收防重放。
|
||||
const (
|
||||
webhookBodyLimit = 256 << 10
|
||||
webhookMaxTimeSkew = 5 * time.Minute
|
||||
msgTypeSubscription = "SubscriptionConfirmation"
|
||||
msgTypeUnsubscribe = "UnsubscribeConfirmation"
|
||||
)
|
||||
|
||||
// webhookEvents 是 webhook 端点对回传服务的依赖切面,测试以假实现替换。
|
||||
type webhookEvents interface {
|
||||
ResolveSecret(ctx context.Context, secret string) (uint, bool)
|
||||
ConfirmAsync(confirmURL string)
|
||||
Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error
|
||||
}
|
||||
|
||||
// webhookHandler 处理 OCI Notifications 的 HTTPS 订阅投递(JWT 组之外,靠 secret 鉴权)。
|
||||
type webhookHandler struct {
|
||||
events webhookEvents
|
||||
}
|
||||
|
||||
// handle 是 webhook 入口:secret 反查租户后按消息类型分派;
|
||||
// 同步路径只做 secret→分派→读body→幂等入库,5 秒投递预算内返回(方案A §2.4)。
|
||||
func (h *webhookHandler) handle(c *gin.Context) {
|
||||
cfgID, ok := h.events.ResolveSecret(c.Request.Context(), c.Param("secret"))
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound) // 不暴露端点存在性,响应无 body
|
||||
return
|
||||
}
|
||||
switch c.GetHeader(onsHeaderMessageType) {
|
||||
case msgTypeSubscription:
|
||||
h.confirmSubscription(c)
|
||||
case msgTypeUnsubscribe:
|
||||
log.Printf("ons unsubscribe confirmation received: cfg %d", cfgID)
|
||||
c.Status(http.StatusOK)
|
||||
default:
|
||||
h.ingest(c, cfgID)
|
||||
}
|
||||
}
|
||||
|
||||
// confirmSubscription 校验确认链接域名白名单后异步 GET 激活订阅,立即 200。
|
||||
func (h *webhookHandler) confirmSubscription(c *gin.Context) {
|
||||
confirmURL := c.GetHeader(onsHeaderConfirmURL)
|
||||
parsed, err := url.Parse(confirmURL)
|
||||
if err != nil || parsed.Scheme != "https" || !service.IsOracleHost(parsed.Host) {
|
||||
log.Printf("ons confirmation url rejected: host not in oracle domain")
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.events.ConfirmAsync(confirmURL)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ingest 校验时间戳与消息 ID 后读 body 入库;重复投递靠唯一索引幂等。
|
||||
func (h *webhookHandler) ingest(c *gin.Context, cfgID uint) {
|
||||
if !timestampFresh(c.GetHeader(onsHeaderTimestamp)) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
messageID := c.GetHeader(onsHeaderMessageID)
|
||||
if messageID == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := verifySignature(c.Request.Header); err != nil {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, webhookBodyLimit+1))
|
||||
if err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
truncated := len(body) > webhookBodyLimit
|
||||
if truncated {
|
||||
body = body[:webhookBodyLimit]
|
||||
}
|
||||
if err := h.events.Ingest(c.Request.Context(), cfgID, messageID, body, truncated); err != nil {
|
||||
log.Printf("webhook ingest: %v", err)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// timestampFresh 校验消息时间戳与本地时钟偏差在防重放窗口内;
|
||||
// 头缺失时放行——ONS 是否恒带此头待验签 spike 确认(方案A §2.3),先不误杀。
|
||||
func timestampFresh(value string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
ts, ok := parseOnsTime(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
skew := time.Since(ts)
|
||||
if skew < 0 {
|
||||
skew = -skew
|
||||
}
|
||||
return skew <= webhookMaxTimeSkew
|
||||
}
|
||||
|
||||
// onsTimestampLayouts 覆盖 ONS 实测时间戳格式与标准 RFC3339:
|
||||
// 2026-07-07 spike 抓包为 2026-07-07T05:30:34.779+0000(时区无冒号,严格 RFC3339 解析失败)。
|
||||
var onsTimestampLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999Z0700",
|
||||
}
|
||||
|
||||
// parseOnsTime 按已知格式解析 ONS 时间戳。
|
||||
func parseOnsTime(value string) (time.Time, bool) {
|
||||
for _, layout := range onsTimestampLayouts {
|
||||
if ts, err := time.Parse(layout, value); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// verifySignature 校验 ONS 消息签名。
|
||||
// TODO(方案A §2.3 spike):官方未公开签名 canonical form,待 test-server 抓取
|
||||
// 真实消息确认输入格式后实现;当前按方案约定以 secret+时间戳+幂等先行。
|
||||
func verifySignature(http.Header) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// fakeWebhookEvents 记录 webhook handler 对回传服务的调用,便于断言分派行为。
|
||||
type fakeWebhookEvents struct {
|
||||
secret string
|
||||
cfgID uint
|
||||
confirmed []string
|
||||
ingested []ingestCall
|
||||
ingestErr error
|
||||
}
|
||||
|
||||
type ingestCall struct {
|
||||
cfgID uint
|
||||
messageID string
|
||||
payload string
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) ResolveSecret(_ context.Context, secret string) (uint, bool) {
|
||||
if secret == f.secret {
|
||||
return f.cfgID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) ConfirmAsync(confirmURL string) {
|
||||
f.confirmed = append(f.confirmed, confirmURL)
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) Ingest(_ context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
|
||||
if f.ingestErr != nil {
|
||||
return f.ingestErr
|
||||
}
|
||||
f.ingested = append(f.ingested, ingestCall{cfgID, messageID, string(payload), truncated})
|
||||
return nil
|
||||
}
|
||||
|
||||
// newWebhookEnv 只挂 webhook 路由(与生产同链:Recovery+系统日志中间件),
|
||||
// 返回引擎、假回传服务与系统日志服务(断言留痕脱敏用)。
|
||||
func newWebhookEnv(t *testing.T) (*gin.Engine, *fakeWebhookEvents, *service.SystemLogService, *gorm.DB) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.SystemLog{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
fake := &fakeWebhookEvents{secret: strings.Repeat("ab", 32), cfgID: 7}
|
||||
systemLogs := service.NewSystemLogService(db)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/webhooks/oci-logs/:secret",
|
||||
gin.Recovery(), systemLogMiddleware(systemLogs), (&webhookHandler{events: fake}).handle)
|
||||
return r, fake, systemLogs, db
|
||||
}
|
||||
|
||||
// postWebhook 以 ONS 头发起一次投递请求。
|
||||
func postWebhook(t *testing.T, r *gin.Engine, secret, body string, headers map[string]string) int {
|
||||
t.Helper()
|
||||
req := httptestNewRequest(secret, body, headers)
|
||||
w := doRawRequest(r, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
func httptestNewRequest(secret, body string, headers map[string]string) *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/webhooks/oci-logs/"+secret, strings.NewReader(body))
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// doRawRequest 直接以 *http.Request 走引擎,便于携带任意头。
|
||||
func doRawRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestWebhookDispatch(t *testing.T) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stale := time.Now().Add(-10 * time.Minute).UTC().Format(time.RFC3339)
|
||||
oracleURL := "https://cell1.notification.ap-tokyo-1.oci.oraclecloud.com/confirm?token=x"
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
headers map[string]string
|
||||
body string
|
||||
wantStatus int
|
||||
wantConfirm int
|
||||
wantIngest int
|
||||
}{
|
||||
{
|
||||
name: "secret 不匹配 404", secret: "wrong",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1"}, wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "订阅确认白名单内异步激活", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: oracleURL},
|
||||
wantStatus: http.StatusOK, wantConfirm: 1,
|
||||
},
|
||||
{
|
||||
name: "订阅确认白名单外拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://evil.example.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "订阅确认伪装 host 后缀拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://eviloraclecloud.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "订阅确认非 https 拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "http://x.oraclecloud.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "退订确认仅记录", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeUnsubscribe},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "通知消息入库", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: now},
|
||||
body: `{"eventType":"t"}`,
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
{
|
||||
name: "ONS 无冒号时区格式放行", secret: "",
|
||||
headers: map[string]string{
|
||||
onsHeaderMessageID: "m1",
|
||||
onsHeaderTimestamp: time.Now().UTC().Format("2006-01-02T15:04:05.999") + "+0000",
|
||||
},
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
{
|
||||
name: "时间戳过期拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: stale},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "时间戳非法拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: "yesterday"},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "缺 MessageId 拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderTimestamp: now},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "缺时间戳头放行入库", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m2"},
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, fake, _, _ := newWebhookEnv(t)
|
||||
secret := tt.secret
|
||||
if secret == "" {
|
||||
secret = fake.secret
|
||||
}
|
||||
code := postWebhook(t, r, secret, tt.body, tt.headers)
|
||||
if code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", code, tt.wantStatus)
|
||||
}
|
||||
if len(fake.confirmed) != tt.wantConfirm {
|
||||
t.Errorf("confirm calls = %d, want %d", len(fake.confirmed), tt.wantConfirm)
|
||||
}
|
||||
if len(fake.ingested) != tt.wantIngest {
|
||||
t.Errorf("ingest calls = %d, want %d", len(fake.ingested), tt.wantIngest)
|
||||
}
|
||||
if tt.wantIngest > 0 && fake.ingested[0].cfgID != fake.cfgID {
|
||||
t.Errorf("ingest cfg = %d, want %d", fake.ingested[0].cfgID, fake.cfgID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookBodyTruncation(t *testing.T) {
|
||||
r, fake, _, _ := newWebhookEnv(t)
|
||||
big := strings.Repeat("x", webhookBodyLimit+100)
|
||||
code := postWebhook(t, r, fake.secret, big, map[string]string{onsHeaderMessageID: "m-big"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
got := fake.ingested[0]
|
||||
if !got.truncated || len(got.payload) != webhookBodyLimit {
|
||||
t.Errorf("truncated=%v len=%d, want true/%d", got.truncated, len(got.payload), webhookBodyLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSystemLogHidesSecret(t *testing.T) {
|
||||
r, fake, systemLogs, db := newWebhookEnv(t)
|
||||
postWebhook(t, r, fake.secret, "{}", map[string]string{onsHeaderMessageID: "m1"})
|
||||
systemLogs.Wait()
|
||||
var entry model.SystemLog
|
||||
if err := db.First(&entry).Error; err != nil {
|
||||
t.Fatalf("system log row: %v", err)
|
||||
}
|
||||
if strings.Contains(entry.Path, fake.secret) {
|
||||
t.Errorf("system log path leaks secret: %s", entry.Path)
|
||||
}
|
||||
if !strings.Contains(entry.Path, ":secret") {
|
||||
t.Errorf("path = %s, want route template with :secret", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "webhook 路径脱敏末段", in: "/api/v1/webhooks/oci-logs/abcdef123456", want: "/api/v1/webhooks/oci-logs/***"},
|
||||
{name: "非 webhook 路径原样", in: "/api/v1/tasks/3", want: "/api/v1/tasks/3"},
|
||||
{name: "webhook 前缀本身原样", in: "/api/v1/webhooks/", want: "/api/v1/webhooks/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeLogPath(tt.in); got != tt.want {
|
||||
t.Errorf("sanitizeLogPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user