发布 0.3.0:恢复 Chat 接口并增强云端事件通知
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s

This commit is contained in:
2026-07-13 10:13:44 +08:00
parent 489cb49cb3
commit c7cc5616ed
31 changed files with 1682 additions and 1625 deletions
+182 -2
View File
@@ -1,9 +1,11 @@
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Anthropic Messages /
// Embeddings 与通用记账、错误、模型列表结构。上游统一为 OCI OpenAI 兼容面。
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Chat Completions /
// Anthropic Messages / Embeddings 与通用记账、错误、模型列表结构。上游统一为
// OCI OpenAI 兼容面。
package aiwire
import (
"encoding/json"
"strings"
)
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
@@ -73,3 +75,181 @@ type ErrorDetail struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
}
// ---- Chat Completions 线格式(/ai/v1/chat/completions,Tier 2 兼容层)----
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;只声明网关读取的字段,
// 无对应物的字段(stop / seed / n / penalty 等)由 JSON 解码天然忽略。
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// StreamOptions 控制流式末尾是否附带 usage 块。
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// ChatMessage 是请求与非流式响应共用的消息结构。
type ChatMessage struct {
Role string `json:"role"`
Content Content `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// Content 兼容 string 与多模态块数组两种线格式,序列化时保形。
type Content struct {
Text string
Parts []ContentPart
IsArray bool
}
func (c *Content) UnmarshalJSON(b []byte) error {
t := strings.TrimSpace(string(b))
if t == "null" {
return nil
}
if strings.HasPrefix(t, "[") {
c.IsArray = true
return json.Unmarshal(b, &c.Parts)
}
return json.Unmarshal(b, &c.Text)
}
func (c Content) MarshalJSON() ([]byte, error) {
if c.IsArray {
return json.Marshal(c.Parts)
}
return json.Marshal(c.Text)
}
// NewTextContent 构造字符串形态的内容。
func NewTextContent(text string) Content {
return Content{Text: text}
}
// JoinText 拍平内容为纯文本(数组形态拼接 text 块)。
func (c Content) JoinText() string {
if !c.IsArray {
return c.Text
}
var sb strings.Builder
for _, p := range c.Parts {
if p.Type == "" || p.Type == "text" {
sb.WriteString(p.Text)
}
}
return sb.String()
}
// ContentPart 是块数组中的一段;text 与 image_url 被网关承接,其余类型拒绝。
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址。
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
// Tool 是嵌套形态的 function 工具定义。
type Tool struct {
Type string `json:"type"`
Function FunctionDef `json:"function"`
}
// FunctionDef 描述工具的名称与 JSON Schema 参数。
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// ToolCall 是助手消息中的工具调用。
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
// FunctionCall 携带调用名与 JSON 编码的实参。
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ResponseFormat 对应 response_format(json_object / json_schema)。
type ResponseFormat struct {
Type string `json:"type"`
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
}
// ChatResponse 是非流式响应体(chat.completion)。
type ChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// Choice 是一条候选回复。
type Choice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
type ChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// ChunkChoice 是流式事件中的增量候选。
type ChunkChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// Delta 是流式增量内容;Content 为纯文本片段。
type Delta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
}
// ToolCallDelta 是工具调用的流式片段;Index 标识聚合目标。
type ToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function FunctionCallDelta `json:"function"`
}
// FunctionCallDelta 是函数名 / 实参的增量片段。
type FunctionCallDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
+108 -4
View File
@@ -281,7 +281,7 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
defer upstream.Close()
sseHeaders(c)
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
if err := forwardAnthSSE(c, upstream, bridge); err != nil {
if err := forwardSSEData(upstream, func(data []byte) { writeAnthEvents(c, bridge.Feed(data)) }); err != nil {
entry.ErrMsg = err.Error()
}
writeAnthEvents(c, bridge.Finish())
@@ -295,15 +295,16 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
}
// forwardAnthSSE 逐行读上游 SSE,把 data 行喂给桥并即时写出转换后的事件。
func forwardAnthSSE(c *gin.Context, upstream io.Reader, bridge *service.AnthRespBridge) error {
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
// Anthropic 与 Chat Completions 两条流式桥共用。
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
reader := bufio.NewReader(upstream)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
trimmed := bytes.TrimSpace(line)
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
writeAnthEvents(c, bridge.Feed(data))
emit(data)
}
}
if err != nil {
@@ -328,6 +329,109 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
c.Writer.Flush()
}
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层,
// 承接只会说 Chat Completions 的存量客户端)。
//
// @Summary OpenAI Chat Completions 兼容端点
// @Tags AI 网关
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])"
// @Router /ai/v1/chat/completions [post]
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
var req aiwire.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空")
return
}
if !checkKeyModel(c, req.Model) {
return
}
body, err := service.ChatToResponsesBody(req)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamChat(c, body, req)
return
}
h.chatOnce(c, body, req)
}
// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。
func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) {
start := time.Now()
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "openai", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
if err != nil {
aiError(c, http.StatusBadGateway, "api_error", err.Error())
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
fillUsage(&entry, out.Usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", req.Model, false, req, out)
c.JSON(http.StatusOK, out)
}
// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为
// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。
func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) {
start := time.Now()
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "openai", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
defer upstream.Close()
sseHeaders(c)
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
if err := forwardSSEData(upstream, func(data []byte) { writeChatChunks(c, bridge.Feed(data)) }); err != nil {
entry.ErrMsg = err.Error()
}
writeChatChunks(c, bridge.Finish())
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, bridge.Usage())
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
}
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
for _, ch := range chunks {
b, err := json.Marshal(ch)
if err != nil {
continue
}
c.Writer.WriteString("data: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
}
if len(chunks) > 0 {
c.Writer.Flush()
}
}
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
//
// @Summary 可用模型列表
+12 -2
View File
@@ -81,8 +81,18 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
`{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny},
{"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses",
`{"model":"ghost-model","input":"hi"}`, 404, pass},
{"chat completions 端点已删除", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, []string{"not found"}},
{"chat completions 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions",
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`,
400, []string{"仅支持 function"}},
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
400, []string{"invalid_request_error"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
-122
View File
@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
@@ -107,127 +106,6 @@ func (h *logEventHandler) list(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
// alertRuleRequest 是创建/更新告警规则的请求体。
type alertRuleRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"`
EventTypes string `json:"eventTypes"`
SourceIPs string `json:"sourceIps"`
SourceIPMode string `json:"sourceIpMode"`
ResourceMatch string `json:"resourceMatch"`
Threshold int `json:"threshold"`
WindowMinutes int `json:"windowMinutes"`
}
// toModel 转为规则模型(默认阈值 1)。
func (r alertRuleRequest) toModel() model.AlertRule {
if r.Threshold == 0 {
r.Threshold = 1
}
return model.AlertRule{
Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID,
EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode,
ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes,
}
}
// listAlertRules 返回全部告警规则。
//
// @Summary 告警规则列表
// @Tags 任务与日志回传
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [get]
func (h *logEventHandler) listAlertRules(c *gin.Context) {
rules, err := h.svc.ListAlertRules(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": rules})
}
// createAlertRule 创建告警规则;字段非法返回 400。
//
// @Summary 创建告警规则
// @Tags 任务与日志回传
// @Param body body alertRuleRequest true "请求体"
// @Success 201 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [post]
func (h *logEventHandler) createAlertRule(c *gin.Context) {
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusCreated, rule)
}
// updateAlertRule 整体覆盖告警规则(含启停)。
//
// @Summary 更新告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Param body body alertRuleRequest true "请求体"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [put]
func (h *logEventHandler) updateAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusOK, rule)
}
// deleteAlertRule 删除告警规则及其命中记录。
//
// @Summary 删除告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [delete]
func (h *logEventHandler) deleteAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// respondAlertRuleError 把字段校验错误映射为 400。
func respondAlertRuleError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidAlertRule) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
respondError(c, err)
}
// getRelay 查询 OCI 侧链路状态与关键事件清单。
//
// @Summary 查询 OCI 侧链路状态与关键事件清单
+1
View File
@@ -11,6 +11,7 @@ import (
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
aih := &aiGatewayHandler{gw: aiGateway}
ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
ai.POST("/chat/completions", aih.chatCompletions)
ai.POST("/responses", aih.responses)
ai.POST("/messages", aih.messages)
ai.POST("/embeddings", aih.embeddings)
-4
View File
@@ -19,10 +19,6 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService,
le := &logEventHandler{svc: logEvents}
secured.GET("/log-events", le.list)
secured.GET("/log-events/alert-rules", le.listAlertRules)
secured.POST("/log-events/alert-rules", le.createAlertRule)
secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule)
secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule)
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)
+1 -1
View File
@@ -67,7 +67,7 @@ func autoMigrate(db *gorm.DB) error {
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.CheckSnapshot{}, &model.CostSnapshot{},
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
&model.SystemLog{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.Proxy{},
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
&model.AiContentLog{},
)
-25
View File
@@ -211,31 +211,6 @@ type LogEvent struct {
ReceivedAt time.Time `json:"receivedAt"`
}
// AlertRule 是回传事件的自定义告警规则;条件间 AND 关系,空条件视为任意。
type AlertRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64" json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"` // 0=全部租户
EventTypes string `gorm:"size:512" json:"eventTypes"` // 逗号分隔事件短名,空=全部
SourceIPs string `gorm:"size:512" json:"sourceIps"` // 逗号分隔 IP/CIDR,空=任意
// in:来源命中列表才告警(默认);notin:不在列表才告警(白名单场景)
SourceIPMode string `gorm:"size:8" json:"sourceIpMode"`
ResourceMatch string `gorm:"size:128" json:"resourceMatch"` // 资源名子串,空=任意
Threshold int `json:"threshold"` // 触发阈值,默认 1(即时)
WindowMinutes int `json:"windowMinutes"` // 聚合窗口,Threshold>1 时必填
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// AlertRuleHit 记录规则命中,供阈值窗口计数;随周期清理删除过期行。
type AlertRuleHit struct {
ID uint `gorm:"primaryKey" json:"id"`
RuleID uint `gorm:"index" json:"ruleId"`
LogEventID uint `json:"logEventId"`
HitAt time.Time `gorm:"index" json:"hitAt"`
}
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
type RegionCache struct {
+12 -4
View File
@@ -73,12 +73,20 @@ func RespPassthroughUsage(payload []byte) *aiwire.Usage {
var root struct {
Usage *aiwire.RespUsage `json:"usage"`
}
if json.Unmarshal(payload, &root) != nil || root.Usage == nil {
if json.Unmarshal(payload, &root) != nil {
return nil
}
usage := &aiwire.Usage{PromptTokens: root.Usage.InputTokens,
CompletionTokens: root.Usage.OutputTokens, TotalTokens: root.Usage.TotalTokens}
if cached := root.Usage.InputTokensDetails.CachedTokens; cached > 0 {
return usageFromResp(root.Usage)
}
// usageFromResp 换算 Responses usage 为 OpenAI 口径(缓存命中透传);nil 原样返回。
func usageFromResp(u *aiwire.RespUsage) *aiwire.Usage {
if u == nil {
return nil
}
usage := &aiwire.Usage{PromptTokens: u.InputTokens,
CompletionTokens: u.OutputTokens, TotalTokens: u.TotalTokens}
if cached := u.InputTokensDetails.CachedTokens; cached > 0 {
usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached}
}
return usage
-305
View File
@@ -1,305 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"slices"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
)
// 告警规则约束与命中记录保留期(窗口计数之外多留几天便于排查)。
const (
alertMaxThreshold = 100
alertMaxWindowMin = 1440
alertHitRetention = 7 * 24 * time.Hour
alertSourceIPIn = "in"
alertSourceIPNotIn = "notin"
)
// ErrInvalidAlertRule 标记规则字段非法,api 层映射 400。
var ErrInvalidAlertRule = fmt.Errorf("告警规则字段非法")
// ListAlertRules 返回全部告警规则(创建顺序)。
func (s *LogEventService) ListAlertRules(ctx context.Context) ([]model.AlertRule, error) {
var rules []model.AlertRule
if err := s.db.WithContext(ctx).Order("id").Find(&rules).Error; err != nil {
return nil, fmt.Errorf("list alert rules: %w", err)
}
return rules, nil
}
// CreateAlertRule 校验并创建规则。
func (s *LogEventService) CreateAlertRule(ctx context.Context, rule model.AlertRule) (model.AlertRule, error) {
if err := validateAlertRule(&rule); err != nil {
return model.AlertRule{}, err
}
rule.ID = 0
if err := s.db.WithContext(ctx).Create(&rule).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("create alert rule: %w", err)
}
return rule, nil
}
// UpdateAlertRule 校验并整体覆盖规则(含启停)。
func (s *LogEventService) UpdateAlertRule(ctx context.Context, id uint, rule model.AlertRule) (model.AlertRule, error) {
if err := validateAlertRule(&rule); err != nil {
return model.AlertRule{}, err
}
var cur model.AlertRule
if err := s.db.WithContext(ctx).First(&cur, id).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("find alert rule %d: %w", id, err)
}
rule.ID, rule.CreatedAt = cur.ID, cur.CreatedAt
if err := s.db.WithContext(ctx).Save(&rule).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("update alert rule: %w", err)
}
return rule, nil
}
// DeleteAlertRule 删除规则及其命中记录。
func (s *LogEventService) DeleteAlertRule(ctx context.Context, id uint) error {
if err := s.db.WithContext(ctx).Delete(&model.AlertRule{}, id).Error; err != nil {
return fmt.Errorf("delete alert rule: %w", err)
}
if err := s.db.WithContext(ctx).Where("rule_id = ?", id).Delete(&model.AlertRuleHit{}).Error; err != nil {
return fmt.Errorf("delete alert rule hits: %w", err)
}
return nil
}
// validateAlertRule 校验字段并归一化;非法时返回含具体原因的 ErrInvalidAlertRule 包装。
func validateAlertRule(rule *model.AlertRule) error {
rule.Name = strings.TrimSpace(rule.Name)
if rule.Name == "" {
return fmt.Errorf("%w: 名称必填", ErrInvalidAlertRule)
}
if rule.SourceIPMode == "" {
rule.SourceIPMode = alertSourceIPIn
}
if rule.SourceIPMode != alertSourceIPIn && rule.SourceIPMode != alertSourceIPNotIn {
return fmt.Errorf("%w: 来源 IP 模式须为 in/notin", ErrInvalidAlertRule)
}
if rule.Threshold < 1 || rule.Threshold > alertMaxThreshold {
return fmt.Errorf("%w: 阈值须在 1-%d 之间", ErrInvalidAlertRule, alertMaxThreshold)
}
if rule.Threshold > 1 && (rule.WindowMinutes < 1 || rule.WindowMinutes > alertMaxWindowMin) {
return fmt.Errorf("%w: 阈值>1 时窗口须在 1-%d 分钟之间", ErrInvalidAlertRule, alertMaxWindowMin)
}
if rule.EventTypes != "" {
rule.EventTypes = normalizeCSV(rule.EventTypes)
}
return validateAlertRuleIPs(rule)
}
// validateAlertRuleIPs 归一化并校验来源 IP 列表(裸 IP 或 CIDR)。
func validateAlertRuleIPs(rule *model.AlertRule) error {
if rule.SourceIPs == "" {
return nil
}
rule.SourceIPs = normalizeCSV(rule.SourceIPs)
for _, item := range strings.Split(rule.SourceIPs, ",") {
if _, err := parseIPMatcher(item); err != nil {
return fmt.Errorf("%w: 来源 IP %q 不是合法的 IP 或 CIDR", ErrInvalidAlertRule, item)
}
}
return nil
}
// normalizeCSV 去除各项空白与空项后重组逗号分隔串。
func normalizeCSV(s string) string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return strings.Join(out, ",")
}
// parseIPMatcher 把裸 IP 或 CIDR 解析为前缀(裸 IP 视为单地址前缀)。
func parseIPMatcher(item string) (netip.Prefix, error) {
if strings.Contains(item, "/") {
return netip.ParsePrefix(item)
}
addr, err := netip.ParseAddr(item)
if err != nil {
return netip.Prefix{}, err
}
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
// ipListMatch 报告 ip 是否命中列表中的任一前缀;ip 解析失败视为未命中。
func ipListMatch(list, ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
for _, item := range strings.Split(list, ",") {
if p, err := parseIPMatcher(item); err == nil && p.Contains(addr) {
return true
}
}
return false
}
// ruleHits 报告事件是否命中规则的全部条件(AND 语义,空条件视为任意)。
func ruleHits(rule model.AlertRule, e *model.LogEvent, p parsedEvent) bool {
if rule.OciConfigID != 0 && rule.OciConfigID != e.OciConfigID {
return false
}
name := relayEventShortName(p.EventType)
if rule.EventTypes != "" && !slices.Contains(strings.Split(rule.EventTypes, ","), name) {
return false
}
if rule.ResourceMatch != "" && !strings.Contains(p.ResourceName, rule.ResourceMatch) {
return false
}
return ruleIPHits(rule, p.SourceIP)
}
// ruleIPHits 按模式判定来源 IP 条件:in 命中列表告警;notin 不在列表才告警,
// 事件缺 IP 字段时 notin 不告警(避免解析缺字段导致白名单误报)。
func ruleIPHits(rule model.AlertRule, ip string) bool {
if rule.SourceIPs == "" {
return true
}
if rule.SourceIPMode == alertSourceIPNotIn {
return ip != "" && !ipListMatch(rule.SourceIPs, ip)
}
return ipListMatch(rule.SourceIPs, ip)
}
// matchAlertRules 对一条已解析事件执行全部启用规则;任何内部错误只记日志,不影响解析主流程。
func (s *LogEventService) matchAlertRules(ctx context.Context, rules []model.AlertRule, e *model.LogEvent, p parsedEvent) {
if s.notifier == nil {
return
}
for _, rule := range rules {
if !rule.Enabled || !ruleHits(rule, e, p) {
continue
}
count, ok := s.recordAlertHit(ctx, rule, e)
if !ok || count < rule.Threshold || !s.alertCooldownPass(rule) {
continue
}
s.notifier.SendTemplateAsync("audit_alert", map[string]string{
"rule": rule.Name, "tenant": s.configAlias(ctx, e.OciConfigID),
"event": relayEventShortName(p.EventType), "resource": p.ResourceName,
"ip": p.SourceIP, "count": fmt.Sprint(count),
})
}
}
// recordAlertHit 落一条命中并返回窗口内累计次数;阈值 1 的规则免计数直接触发。
func (s *LogEventService) recordAlertHit(ctx context.Context, rule model.AlertRule, e *model.LogEvent) (int, bool) {
count, err := s.recordAlertHitTx(ctx, rule, e)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("alert rule hit record: %v", err)
}
return 0, false
}
return count, true
}
func (s *LogEventService) recordAlertHitTx(ctx context.Context, rule model.AlertRule, event *model.LogEvent) (int, error) {
count := 0
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
count, err = insertAlertHit(tx, rule, event)
return err
})
return count, err
}
// insertAlertHit 按 rule→event 锁顺序确认引用存在后插入并统计窗口命中。
func insertAlertHit(tx *gorm.DB, rule model.AlertRule, event *model.LogEvent) (int, error) {
if err := lockAlertRefs(tx, rule.ID, event.ID); err != nil {
return 0, err
}
if rule.Threshold <= 1 {
return 1, nil
}
now := time.Now()
hit := model.AlertRuleHit{RuleID: rule.ID, LogEventID: event.ID, HitAt: now}
if err := tx.Create(&hit).Error; err != nil {
return 0, fmt.Errorf("create alert rule hit: %w", err)
}
var count int64
cutoff := now.Add(-time.Duration(rule.WindowMinutes) * time.Minute)
err := tx.Model(&model.AlertRuleHit{}).
Where("rule_id = ? AND hit_at >= ?", rule.ID, cutoff).Count(&count).Error
if err != nil {
return 0, fmt.Errorf("count alert rule hits: %w", err)
}
return int(count), nil
}
func lockAlertRefs(tx *gorm.DB, ruleID, eventID uint) error {
var rule model.AlertRule
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&rule, ruleID).Error; err != nil {
return fmt.Errorf("lock alert rule %d: %w", ruleID, err)
}
var event model.LogEvent
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&event, eventID).Error; err != nil {
return fmt.Errorf("lock log event %d: %w", eventID, err)
}
return nil
}
// alertCooldownPass 报告规则是否已过冷却窗口;通过即记录本次发送时刻。
// 阈值 1 的规则无冷却(每次命中即时告警,与既有云端事件通知一致)。
func (s *LogEventService) alertCooldownPass(rule model.AlertRule) bool {
if rule.Threshold <= 1 {
return true
}
s.alertMu.Lock()
defer s.alertMu.Unlock()
window := time.Duration(rule.WindowMinutes) * time.Minute
if last, ok := s.alertSentAt[rule.ID]; ok && time.Since(last) < window {
return false
}
if s.alertSentAt == nil {
s.alertSentAt = map[uint]time.Time{}
}
s.alertSentAt[rule.ID] = time.Now()
return true
}
// ClearAlertCooldown 清除已删除租户规则的进程内冷却状态。
func (s *LogEventService) ClearAlertCooldown(ruleIDs []uint) {
s.alertMu.Lock()
defer s.alertMu.Unlock()
for _, id := range ruleIDs {
delete(s.alertSentAt, id)
}
}
// loadEnabledAlertRules 载入启用中的规则;失败时返回空集并记日志(解析主流程照常)。
func (s *LogEventService) loadEnabledAlertRules(ctx context.Context) []model.AlertRule {
var rules []model.AlertRule
err := s.db.WithContext(ctx).Where("enabled = ?", true).Order("id").Find(&rules).Error
if err != nil {
log.Printf("load alert rules: %v", err)
return nil
}
return rules
}
// cleanupAlertHits 删除保留期外的命中记录(随 cleanupOnce 周期执行)。
func (s *LogEventService) cleanupAlertHits(ctx context.Context) {
cutoff := time.Now().Add(-alertHitRetention)
if err := s.db.WithContext(ctx).Where("hit_at < ?", cutoff).Delete(&model.AlertRuleHit{}).Error; err != nil {
log.Printf("cleanup alert hits: %v", err)
}
}
-345
View File
@@ -1,345 +0,0 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
func TestValidateAlertRule(t *testing.T) {
tests := []struct {
name string
rule model.AlertRule
wantErr string
check func(t *testing.T, r model.AlertRule)
}{
{name: "名称必填", rule: model.AlertRule{Threshold: 1}, wantErr: "名称"},
{name: "模式非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: "any"}, wantErr: "in/notin"},
{name: "阈值越界", rule: model.AlertRule{Name: "r", Threshold: 101}, wantErr: "阈值"},
{name: "阈值>1须带窗口", rule: model.AlertRule{Name: "r", Threshold: 3}, wantErr: "窗口"},
{name: "IP 非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "300.1.1.1"}, wantErr: "IP"},
{name: "CIDR 合法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "10.0.0.0/8, 1.2.3.4"},
check: func(t *testing.T, r model.AlertRule) {
if r.SourceIPs != "10.0.0.0/8,1.2.3.4" {
t.Errorf("SourceIPs = %q, 应去空白归一化", r.SourceIPs)
}
if r.SourceIPMode != alertSourceIPIn {
t.Errorf("SourceIPMode = %q, 应默认 in", r.SourceIPMode)
}
}},
{name: "事件清单归一化", rule: model.AlertRule{Name: "r", Threshold: 1, EventTypes: " TerminateInstance , CreateApiKey ,"},
check: func(t *testing.T, r model.AlertRule) {
if r.EventTypes != "TerminateInstance,CreateApiKey" {
t.Errorf("EventTypes = %q", r.EventTypes)
}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := tt.rule
err := validateAlertRule(&rule)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validateAlertRule: %v", err)
}
if tt.check != nil {
tt.check(t, rule)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestRuleHits(t *testing.T) {
base := model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: alertSourceIPIn}
ev := &model.LogEvent{OciConfigID: 7}
parsed := parsedEvent{
EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
SourceIP: "203.0.113.8",
ResourceName: "web-server-1",
}
tests := []struct {
name string
mod func(r *model.AlertRule)
p *parsedEvent
want bool
}{
{name: "空条件全命中", mod: func(r *model.AlertRule) {}, want: true},
{name: "租户匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 7 }, want: true},
{name: "租户不匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 8 }, want: false},
{name: "事件短名命中", mod: func(r *model.AlertRule) { r.EventTypes = "LaunchInstance,TerminateInstance" }, want: true},
{name: "事件不在清单", mod: func(r *model.AlertRule) { r.EventTypes = "CreateUser" }, want: false},
{name: "资源子串命中", mod: func(r *model.AlertRule) { r.ResourceMatch = "web-" }, want: true},
{name: "资源不含", mod: func(r *model.AlertRule) { r.ResourceMatch = "db-" }, want: false},
{name: "IP in 命中 CIDR", mod: func(r *model.AlertRule) { r.SourceIPs = "203.0.113.0/24" }, want: true},
{name: "IP in 未命中", mod: func(r *model.AlertRule) { r.SourceIPs = "10.0.0.0/8" }, want: false},
{name: "IP notin 白名单外告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
}, want: true},
{name: "IP notin 白名单内不告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "203.0.113.8", alertSourceIPNotIn
}, want: false},
{name: "notin 事件缺 IP 不告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
}, p: &parsedEvent{EventType: parsed.EventType}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := base
tt.mod(&rule)
p := parsed
if tt.p != nil {
p = *tt.p
}
if got := ruleHits(rule, ev, p); got != tt.want {
t.Errorf("ruleHits = %v, want %v", got, tt.want)
}
})
}
}
func TestAlertRuleCRUD(t *testing.T) {
svc, _, _ := newLogEventEnv(t)
ctx := context.Background()
created, err := svc.CreateAlertRule(ctx, model.AlertRule{Name: "非白名单终止", Enabled: true, Threshold: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 {
t.Fatal("create 未回填 ID")
}
if _, err := svc.CreateAlertRule(ctx, model.AlertRule{Threshold: 1}); err == nil {
t.Fatal("空名称应校验失败")
}
created.Enabled = false
created.EventTypes = "TerminateInstance"
updated, err := svc.UpdateAlertRule(ctx, created.ID, created)
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Enabled || updated.EventTypes != "TerminateInstance" {
t.Fatalf("update 未生效: %+v", updated)
}
rules, err := svc.ListAlertRules(ctx)
if err != nil || len(rules) != 1 {
t.Fatalf("list = %v, %v", rules, err)
}
if err := svc.DeleteAlertRule(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if rules, _ := svc.ListAlertRules(ctx); len(rules) != 0 {
t.Fatalf("delete 后仍有 %d 条", len(rules))
}
}
// auditEventPayload 构造一条含资源与来源 IP 的 CloudEvents 审计消息。
func auditEventPayload(event, resource, ip string) string {
return fmt.Sprintf(`{"eventType":"com.oraclecloud.ComputeApi.%s","source":"ComputeApi",`+
`"eventTime":"2026-07-10T08:00:00Z","data":{"resourceName":%q,"identity":{"ipAddress":%q}}}`,
event, resource, ip)
}
// newAlertNotifyEnv 组装带假 Telegram 通道的告警测试环境。
func newAlertNotifyEnv(t *testing.T) (*LogEventService, *telegramCapture, func()) {
t.Helper()
svc, db, _ := newLogEventEnv(t)
srv, rec := newFakeTelegram(t, `{"ok":true}`)
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
settings := NewSettingService(db, cipher)
token := "123456:AAfake"
if err := settings.UpdateTelegram(context.Background(),
UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}); err != nil {
t.Fatalf("update telegram: %v", err)
}
n := NewNotifier(settings)
n.base = srv.URL
svc.SetNotifier(n, settings)
return svc, rec, n.Wait
}
func TestMatchAlertRulesNotify(t *testing.T) {
svc, rec, wait := newAlertNotifyEnv(t)
ctx := context.Background()
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
Name: "白名单外终止", Enabled: true, Threshold: 1,
EventTypes: "TerminateInstance", SourceIPs: "10.0.0.0/8", SourceIPMode: alertSourceIPNotIn,
})
if err != nil {
t.Fatalf("create rule: %v", err)
}
// 命中:白名单外 IP;不命中:白名单内 IP
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "203.0.113.8"))
mustIngest(t, svc, "m2", auditEventPayload("TerminateInstance", "web-2", "10.1.2.3"))
svc.parseOnce(ctx)
wait()
alerts := auditAlerts(rec.snapshot())
joined := strings.Join(alerts, "\n---\n")
if !strings.Contains(joined, "白名单外终止") || !strings.Contains(joined, "web-1") {
t.Fatalf("应收到含规则名与资源的告警,got %q", joined)
}
if strings.Contains(joined, "web-2") {
t.Fatalf("白名单内事件不应告警,got %q", joined)
}
}
// auditAlerts 过滤出审计告警推送(排除既有 notifyCritical 的云端事件通知)。
func auditAlerts(texts []string) []string {
var out []string
for _, s := range texts {
if strings.Contains(s, "审计告警") {
out = append(out, s)
}
}
return out
}
func TestAlertThresholdWindow(t *testing.T) {
svc, rec, wait := newAlertNotifyEnv(t)
ctx := context.Background()
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
Name: "登录风暴", Enabled: true, Threshold: 3, WindowMinutes: 5, EventTypes: "InteractiveLogin",
})
if err != nil {
t.Fatalf("create rule: %v", err)
}
for i := 1; i <= 4; i++ {
mustIngest(t, svc, fmt.Sprint("login-", i),
auditEventPayload("InteractiveLogin", "user@x.com", "203.0.113.8"))
}
svc.parseOnce(ctx)
wait()
alerts := auditAlerts(rec.snapshot())
if len(alerts) != 1 {
t.Fatalf("窗口内 4 次命中应只告警 1 次(第 3 次触发后冷却),got %d 条: %v", len(alerts), alerts)
}
if !strings.Contains(alerts[0], "3 次") {
t.Errorf("告警文案应含累计次数,got %q", alerts[0])
}
}
// TestAlertRuleBadDataDoesNotBlockParse 验证规则表异常不影响解析主流程。
func TestAlertRuleBadDataDoesNotBlockParse(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
ctx := context.Background()
// 直插一条绕过校验的坏规则(IP 列表非法)
bad := model.AlertRule{Name: "bad", Enabled: true, Threshold: 1, SourceIPs: "not-an-ip"}
if err := db.Create(&bad).Error; err != nil {
t.Fatalf("insert bad rule: %v", err)
}
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "1.2.3.4"))
svc.parseOnce(ctx)
var e model.LogEvent
if err := db.First(&e, "message_id = ?", "m1").Error; err != nil {
t.Fatalf("find event: %v", err)
}
if !e.Processed {
t.Fatal("坏规则不应阻塞事件解析")
}
}
// mustIngest 落一条回传事件,失败即终止测试。
func mustIngest(t *testing.T, svc *LogEventService, msgID, payload string) {
t.Helper()
if err := svc.Ingest(context.Background(), 1, msgID, []byte(payload), false); err != nil {
t.Fatalf("ingest %s: %v", msgID, err)
}
}
// TestCleanupAlertHits 验证过期命中记录随清理删除。
func TestCleanupAlertHits(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
old := model.AlertRuleHit{RuleID: 1, HitAt: time.Now().Add(-8 * 24 * time.Hour)}
fresh := model.AlertRuleHit{RuleID: 1, HitAt: time.Now()}
if err := db.Create(&old).Error; err != nil {
t.Fatalf("insert: %v", err)
}
if err := db.Create(&fresh).Error; err != nil {
t.Fatalf("insert: %v", err)
}
svc.cleanupAlertHits(context.Background())
var count int64
db.Model(&model.AlertRuleHit{}).Count(&count)
if count != 1 {
t.Fatalf("清理后应剩 1 条,got %d", count)
}
}
func TestClearAlertCooldown(t *testing.T) {
svc := NewLogEventService(nil)
rule1 := model.AlertRule{ID: 1, Threshold: 2, WindowMinutes: 10}
rule2 := model.AlertRule{ID: 2, Threshold: 2, WindowMinutes: 10}
if !svc.alertCooldownPass(rule1) || !svc.alertCooldownPass(rule2) {
t.Fatal("首次命中应通过冷却检查")
}
svc.ClearAlertCooldown([]uint{rule1.ID})
if !svc.alertCooldownPass(rule1) {
t.Fatal("已清理规则应重新通过冷却检查")
}
if svc.alertCooldownPass(rule2) {
t.Fatal("未清理规则不应通过冷却检查")
}
}
func TestRecordAlertHitRejectsMissingRefs(t *testing.T) {
tests := []struct {
name string
deleteRule bool
}{
{name: "规则已删除", deleteRule: true},
{name: "事件已删除"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testRecordAlertHitMissingRef(t, tt.deleteRule)
})
}
}
func testRecordAlertHitMissingRef(t *testing.T, deleteRule bool) {
t.Helper()
svc, db, cfgID := newLogEventEnv(t)
rule := model.AlertRule{Name: "r", OciConfigID: cfgID, Threshold: 2, WindowMinutes: 5}
event := model.LogEvent{OciConfigID: cfgID, MessageID: "m"}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("create rule: %v", err)
}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
var err error
if deleteRule {
err = db.Delete(&rule).Error
} else {
err = db.Delete(&event).Error
}
if err != nil {
t.Fatalf("delete reference: %v", err)
}
if count, ok := svc.recordAlertHit(context.Background(), rule, &event); ok || count != 0 {
t.Fatalf("record missing refs = (%d,%v), want (0,false)", count, ok)
}
var hits int64
db.Model(&model.AlertRuleHit{}).Count(&hits)
if hits != 0 {
t.Fatalf("orphan alert hits = %d, want 0", hits)
}
}
+6 -7
View File
@@ -83,7 +83,7 @@ func anthInputItems(messages []aiwire.AnthMessage) ([]any, error) {
func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, err error) {
switch b.Type {
case "text":
return nil, anthTextPart(role, b.Text), nil
return nil, respTextPart(role, b.Text), nil
case "image":
part, err = anthImageInput(b.Source)
return nil, part, err
@@ -100,8 +100,9 @@ func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any,
}
}
// anthTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text)
func anthTextPart(role, text string) map[string]any {
// respTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text);
// Anthropic 与 Chat Completions 两条转换链共用。
func respTextPart(role, text string) map[string]any {
if role == "assistant" {
return map[string]any{"type": "output_text", "text": text}
}
@@ -219,8 +220,7 @@ func ResponsesToAnthropic(payload []byte, msgID string) (*aiwire.MessagesRespons
out.StopReason = "tool_use"
}
}
if resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
resp.IncompleteDetails.Reason == "max_output_tokens" {
if respTruncated(&resp) {
out.StopReason = "max_tokens"
}
out.Usage = anthUsageFromResp(resp.Usage)
@@ -337,8 +337,7 @@ func (st *AnthRespBridge) finishFrom(resp *respPayload) {
return
}
st.usage = anthUsageFromResp(resp.Usage)
if resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
resp.IncompleteDetails.Reason == "max_output_tokens" {
if respTruncated(resp) {
st.stopReason = "max_tokens"
}
}
+360
View File
@@ -0,0 +1,360 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// OpenAI Chat Completions ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
// 端点定位 Tier 2 兼容层(承接只会说 CC 的存量客户端),机制与 anthresponses.go
// 同构。语义损失(README 披露):stop / seed / n / penalty 等无对应字段,忽略;
// 上游 reasoning 输出项与增量事件丢弃;非 function 工具类型拒绝。
// ChatToResponsesBody 把 Chat Completions 请求转为直通 body(强制 store:false)。
func ChatToResponsesBody(req aiwire.ChatRequest) ([]byte, error) {
input, instructions, err := chatInputItems(req.Messages)
if err != nil {
return nil, err
}
body := map[string]any{"model": req.Model, "input": input, "store": false}
if instructions != "" {
body["instructions"] = instructions
}
chatSampling(body, req)
if err := chatBodyTools(body, req); err != nil {
return nil, err
}
if tf := chatTextFormat(req.ResponseFormat); tf != nil {
body["text"] = map[string]any{"format": tf}
}
if req.ReasoningEffort != "" {
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.ReasoningEffort)}
}
if req.Stream {
body["stream"] = true
}
return json.Marshal(body)
}
// chatSampling 装配采样与输出预算;max_completion_tokens 优先于已弃用的 max_tokens。
func chatSampling(body map[string]any, req aiwire.ChatRequest) {
if req.MaxCompletionTokens != nil {
body["max_output_tokens"] = *req.MaxCompletionTokens
} else if req.MaxTokens != nil {
body["max_output_tokens"] = *req.MaxTokens
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
if req.TopP != nil {
body["top_p"] = *req.TopP
}
if req.ParallelToolCalls != nil {
body["parallel_tool_calls"] = *req.ParallelToolCalls
}
}
// chatBodyTools 装配工具与 tool_choice;非 function 工具类型拒绝
// (web_search 等新能力仅在 Responses / Messages 端点供给)。
func chatBodyTools(body map[string]any, req aiwire.ChatRequest) error {
if len(req.Tools) == 0 {
return nil
}
tools := make([]map[string]any, 0, len(req.Tools))
for _, t := range req.Tools {
if t.Type != "function" {
return fmt.Errorf("不支持的工具类型 %q:该端点仅支持 function 工具", t.Type)
}
tools = append(tools, map[string]any{"type": "function", "name": t.Function.Name,
"description": t.Function.Description, "parameters": t.Function.Parameters})
}
body["tools"] = tools
if tc := chatRespToolChoice(req.ToolChoice); tc != nil {
body["tool_choice"] = tc
}
return nil
}
// chatInputItems 把消息序列展开为 Responses input 项与 instructions:
// system/developer 文本聚为 instructions;tool 消息为 function_call_output 项;
// 其余消息经 chatMessageItems 展开,保持相对顺序。
func chatInputItems(messages []aiwire.ChatMessage) ([]any, string, error) {
items := make([]any, 0, len(messages))
var sys []string
for _, m := range messages {
switch m.Role {
case "system", "developer":
if txt := m.Content.JoinText(); txt != "" {
sys = append(sys, txt)
}
case "tool":
items = append(items, map[string]any{"type": "function_call_output",
"call_id": m.ToolCallID, "output": m.Content.JoinText()})
default:
msgItems, err := chatMessageItems(m)
if err != nil {
return nil, "", err
}
items = append(items, msgItems...)
}
}
return items, strings.Join(sys, "\n\n"), nil
}
// chatMessageItems 把 user/assistant 消息转为 message 项;assistant 的
// tool_calls 追加为独立 function_call 项(内容在前,与原语序一致)。
func chatMessageItems(m aiwire.ChatMessage) ([]any, error) {
parts, err := chatContentParts(m.Role, m.Content)
if err != nil {
return nil, err
}
items := make([]any, 0, 1+len(m.ToolCalls))
if len(parts) > 0 {
items = append(items, map[string]any{"role": m.Role, "content": parts})
}
for _, tc := range m.ToolCalls {
items = append(items, map[string]any{"type": "function_call", "call_id": tc.ID,
"name": tc.Function.Name, "arguments": tc.Function.Arguments})
}
return items, nil
}
// chatContentParts 把消息内容转为 Responses 部件(文本按角色定型,图片转 input_image)。
func chatContentParts(role string, c aiwire.Content) ([]map[string]any, error) {
if !c.IsArray {
if c.Text == "" {
return nil, nil
}
return []map[string]any{respTextPart(role, c.Text)}, nil
}
parts := make([]map[string]any, 0, len(c.Parts))
for _, p := range c.Parts {
switch p.Type {
case "", "text":
parts = append(parts, respTextPart(role, p.Text))
case "image_url":
if p.ImageURL == nil || p.ImageURL.URL == "" {
return nil, fmt.Errorf("image_url 块缺少 url")
}
// detail 不透传:兼容面对该字段的支持无法证实(实测期间上游视觉请求不稳,
// 无从归因),它仅是质量提示,忽略合法且消除一个风险轴(README 披露)
parts = append(parts, map[string]any{"type": "input_image", "image_url": p.ImageURL.URL})
default:
return nil, ErrAiUnsupportedBlock
}
}
return parts, nil
}
// chatRespToolChoice 映射 tool_choice:string 形态(auto/none/required)原样;
// {"type":"function","function":{"name":N}} 拍平为 Responses 具名形态;其余忽略。
func chatRespToolChoice(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
switch s {
case "auto", "none", "required":
return s
}
return nil
}
var tc struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if json.Unmarshal(raw, &tc) != nil || tc.Type != "function" || tc.Function.Name == "" {
return nil
}
return map[string]string{"type": "function", "name": tc.Function.Name}
}
// chatTextFormat 把 response_format 拍平为 text.format(json_schema 提升嵌套字段)。
func chatTextFormat(rf *aiwire.ResponseFormat) map[string]any {
if rf == nil || rf.Type == "" || rf.Type == "text" {
return nil
}
format := map[string]any{"type": rf.Type}
if rf.Type != "json_schema" || len(rf.JSONSchema) == 0 {
return format
}
var js struct {
Name string `json:"name"`
Schema json.RawMessage `json:"schema"`
Strict *bool `json:"strict"`
}
if json.Unmarshal(rf.JSONSchema, &js) != nil {
return format
}
if js.Name != "" {
format["name"] = js.Name
}
if len(js.Schema) > 0 {
format["schema"] = js.Schema
}
if js.Strict != nil {
format["strict"] = *js.Strict
}
return format
}
// ResponsesToChat 把直通非流式响应转为 Chat Completions 响应(reasoning 项丢弃)。
func ResponsesToChat(payload []byte, id string, created int64) (*aiwire.ChatResponse, error) {
var resp respPayload
if err := json.Unmarshal(payload, &resp); err != nil {
return nil, fmt.Errorf("解析上游响应: %w", err)
}
msg := aiwire.ChatMessage{Role: "assistant"}
var text strings.Builder
for _, item := range resp.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" {
text.WriteString(part.Text)
}
}
case "function_call":
msg.ToolCalls = append(msg.ToolCalls, aiwire.ToolCall{ID: item.CallID, Type: "function",
Function: aiwire.FunctionCall{Name: item.Name, Arguments: item.Arguments}})
}
}
msg.Content = aiwire.NewTextContent(text.String())
choice := aiwire.Choice{Index: 0, Message: msg,
FinishReason: chatFinishReason(&resp, len(msg.ToolCalls) > 0)}
return &aiwire.ChatResponse{ID: id, Object: "chat.completion", Created: created,
Model: resp.Model, Choices: []aiwire.Choice{choice}, Usage: usageFromResp(resp.Usage)}, nil
}
// chatFinishReason 映射终态:截断优先 → length;含工具调用 → tool_calls;默认 stop。
func chatFinishReason(resp *respPayload, hasTools bool) string {
if respTruncated(resp) {
return "length"
}
if hasTools {
return "tool_calls"
}
return "stop"
}
// respTruncated 报告直通响应是否因 max_output_tokens 截断。
func respTruncated(resp *respPayload) bool {
return resp != nil && resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
resp.IncompleteDetails.Reason == "max_output_tokens"
}
// ---- Chat Completions 流式桥:直通 SSE 事件 → chat.completion.chunk 序列 ----
// ChatRespBridge 把直通 SSE 事件流桥接为 chunk 序列:首个增量补 role,文本与
// 工具增量按 OpenAI 形态输出;reasoning 系列事件丢弃;[DONE] 由传输层写出。
type ChatRespBridge struct {
id, model string
created int64
includeUsage bool
started bool
toolIndex int
inTool bool
finish string
usage *aiwire.Usage
}
// NewChatRespBridge 构造桥;includeUsage 即 stream_options.include_usage。
func NewChatRespBridge(id, model string, created int64, includeUsage bool) *ChatRespBridge {
return &ChatRespBridge{id: id, model: model, created: created,
includeUsage: includeUsage, toolIndex: -1, finish: "stop"}
}
// Feed 消费一行 SSE data JSON,返回应立即写出的 chunk。
func (b *ChatRespBridge) Feed(data []byte) []aiwire.ChatChunk {
var ev respStreamEvent
if json.Unmarshal(data, &ev) != nil {
return nil
}
switch ev.Type {
case "response.output_text.delta":
b.inTool = false
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{Content: ev.Delta}), nil)}
case "response.output_item.added":
return b.toolOpen(ev.Item)
case "response.function_call_arguments.delta":
return b.argsChunk(ev.Delta)
case "response.completed", "response.incomplete", "response.failed":
b.finishFrom(ev.Response)
}
return nil
}
// chunk 装配一条单 choice 增量事件。
func (b *ChatRespBridge) chunk(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
return aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", Created: b.created,
Model: b.model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
}
// deltaWithRole 给首个增量补 role(OpenAI 首块携带 role 语义)。
func (b *ChatRespBridge) deltaWithRole(d aiwire.Delta) aiwire.Delta {
if !b.started {
b.started = true
d.Role = "assistant"
}
return d
}
// toolOpen 在新 function_call 输出项开始时发 id/name 增量并推进聚合 index;
// 非工具输出项只复位增量目标。
func (b *ChatRespBridge) toolOpen(item *respOutputItem) []aiwire.ChatChunk {
if item == nil || item.Type != "function_call" {
b.inTool = false
return nil
}
b.toolIndex++
b.inTool = true
b.finish = "tool_calls"
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, ID: item.CallID,
Type: "function", Function: aiwire.FunctionCallDelta{Name: item.Name}}}}
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(d), nil)}
}
// argsChunk 输出当前工具的实参增量(仅在工具输出项进行中)。
func (b *ChatRespBridge) argsChunk(delta string) []aiwire.ChatChunk {
if !b.inTool {
return nil
}
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex,
Function: aiwire.FunctionCallDelta{Arguments: delta}}}}
return []aiwire.ChatChunk{b.chunk(d, nil)}
}
// finishFrom 记录终态 usage 与截断语义(max_output_tokens → length)。
func (b *ChatRespBridge) finishFrom(resp *respPayload) {
if resp == nil {
return
}
b.usage = usageFromResp(resp.Usage)
if respTruncated(resp) {
b.finish = "length"
}
}
// Finish 在上游流结束后收尾:终块带 finish_reason;include_usage 时追加 usage 块
// (choices 为空数组,OpenAI 规范形态;上游未给 usage 时输出零值)。
func (b *ChatRespBridge) Finish() []aiwire.ChatChunk {
chunks := []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{}), &b.finish)}
if !b.includeUsage {
return chunks
}
usage := b.usage
if usage == nil {
usage = &aiwire.Usage{}
}
return append(chunks, aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk",
Created: b.created, Model: b.model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
}
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
+301
View File
@@ -0,0 +1,301 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest {
t.Helper()
var req aiwire.ChatRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("解析请求: %v", err)
}
return req
}
func mustChatBody(t *testing.T, raw string) map[string]any {
t.Helper()
payload, err := ChatToResponsesBody(mustChatReq(t, raw))
if err != nil {
t.Fatalf("ChatToResponsesBody: %v", err)
}
var body map[string]any
if err := json.Unmarshal(payload, &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return body
}
// TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。
func TestChatToResponsesBody(t *testing.T) {
body := mustChatBody(t, `{
"model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128,
"temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true,
"reasoning_effort": "HIGH",
"messages": [
{"role": "system", "content": "你是助手"},
{"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]},
{"role": "user", "content": "东京天气?"},
{"role": "assistant", "content": "查询中", "tool_calls": [
{"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}}
]},
{"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"},
{"role": "user", "content": "继续"}
],
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}`)
if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true {
t.Fatalf("顶层字段装配错误: %v", body)
}
if body["max_output_tokens"] != float64(128) {
t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"])
}
if body["instructions"] != "你是助手\n\n简洁作答" {
t.Fatalf("instructions = %v", body["instructions"])
}
if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false {
t.Fatalf("采样字段装配错误: %v", body)
}
if body["tool_choice"] != "required" {
t.Fatalf("tool_choice = %v", body["tool_choice"])
}
if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" {
t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"])
}
kinds := chatInputKinds(t, body)
want := "message/user,message/assistant,function_call,function_call_output,message/user"
if kinds != want {
t.Fatalf("input 顺序 = %s, want %s", kinds, want)
}
tools, _ := body["tools"].([]any)
tool, _ := tools[0].(map[string]any)
if tool["type"] != "function" || tool["name"] != "get_weather" {
t.Fatalf("工具应拍平为 Responses 形态: %v", tool)
}
}
func chatInputKinds(t *testing.T, body map[string]any) string {
t.Helper()
input, _ := body["input"].([]any)
kinds := make([]string, 0, len(input))
for _, it := range input {
m := it.(map[string]any)
if ty, ok := m["type"].(string); ok {
kinds = append(kinds, ty)
} else {
kinds = append(kinds, "message/"+m["role"].(string))
}
}
return strings.Join(kinds, ",")
}
// TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。
func TestChatToResponsesBodyContent(t *testing.T) {
body := mustChatBody(t, `{"model":"m","messages":[
{"role":"user","content":[
{"type":"text","text":"看图"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]},
{"role":"assistant","content":"这是猫"}]}`)
raw, _ := json.Marshal(body["input"])
if !strings.Contains(string(raw), `"input_image"`) ||
!strings.Contains(string(raw), "data:image/png;base64,QUJD") {
t.Fatalf("图片应转 input_image: %s", raw)
}
if !strings.Contains(string(raw), `"output_text"`) {
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw)
}
for name, msg := range map[string]string{
"audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`,
"图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`,
} {
raw := `{"model":"m","messages":[` + msg + `]}`
if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil {
t.Errorf("%s 应拒绝", name)
}
}
badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"web_search","function":{}}]}`)
if _, err := ChatToResponsesBody(badTool); err == nil {
t.Error("非 function 工具类型应拒绝")
}
}
// TestChatRespToolChoice 断言 tool_choice 各形态映射。
func TestChatRespToolChoice(t *testing.T) {
cases := []struct {
name, raw string
want any
}{
{"auto", `"auto"`, "auto"},
{"none", `"none"`, "none"},
{"required", `"required"`, "required"},
{"具名 function", `{"type":"function","function":{"name":"f1"}}`,
map[string]string{"type": "function", "name": "f1"}},
{"未知 string", `"whatever"`, nil},
{"缺 name", `{"type":"function","function":{}}`, nil},
{"空", ``, nil},
}
for _, tc := range cases {
got := chatRespToolChoice(json.RawMessage(tc.raw))
if gotMap, ok := got.(map[string]string); ok {
wantMap, _ := tc.want.(map[string]string)
if wantMap == nil || gotMap["name"] != wantMap["name"] {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
continue
}
if got != tc.want {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
// TestChatTextFormat 断言 response_format 拍平映射。
func TestChatTextFormat(t *testing.T) {
if got := chatTextFormat(nil); got != nil {
t.Errorf("nil 应不下发: %v", got)
}
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil {
t.Errorf("text 应不下发: %v", got)
}
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" {
t.Errorf("json_object: %v", got)
}
got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema",
JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)})
if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true {
t.Errorf("json_schema 应提升嵌套字段: %v", got)
}
if _, ok := got["schema"]; !ok {
t.Errorf("schema 缺失: %v", got)
}
}
// TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。
func TestResponsesToChat(t *testing.T) {
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
{"type":"reasoning","summary":[]},
{"type":"message","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]},
{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}],
"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`)
out, err := ResponsesToChat(payload, "chatcmpl-1", 1700000000)
if err != nil {
t.Fatalf("ResponsesToChat: %v", err)
}
if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 {
t.Fatalf("响应骨架错误: %+v", out)
}
msg := out.Choices[0].Message
if msg.Role != "assistant" || msg.Content.JoinText() != "你好" {
t.Fatalf("content 装配错误: %+v", msg)
}
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" ||
msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` {
t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls)
}
if out.Choices[0].FinishReason != "tool_calls" {
t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason)
}
if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 4 {
t.Fatalf("usage = %+v", out.Usage)
}
trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},
"output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`)
out2, err := ResponsesToChat(trunc, "chatcmpl-2", 1)
if err != nil || out2.Choices[0].FinishReason != "length" {
t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err)
}
if out2.Usage != nil {
t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage)
}
}
func chatChunkShapes(chunks []aiwire.ChatChunk) string {
kinds := make([]string, 0, len(chunks))
for _, ch := range chunks {
switch {
case len(ch.Choices) == 0:
kinds = append(kinds, "usage")
case ch.Choices[0].FinishReason != nil:
kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason)
case len(ch.Choices[0].Delta.ToolCalls) > 0:
kinds = append(kinds, "tool")
case ch.Choices[0].Delta.Role != "":
kinds = append(kinds, "role+text")
default:
kinds = append(kinds, "text")
}
}
return strings.Join(kinds, ",")
}
// TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。
func TestChatRespBridge(t *testing.T) {
b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true)
var chunks []aiwire.ChatChunk
feed := func(lines ...string) {
for _, l := range lines {
chunks = append(chunks, b.Feed([]byte(l))...)
}
}
feed(`{"type":"response.created","response":{"model":"m1"}}`,
`{"type":"response.reasoning_text.delta","delta":"思考中"}`,
`{"type":"response.output_text.delta","delta":"你"}`,
`{"type":"response.output_text.delta","delta":"好"}`,
`{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`,
`{"type":"response.function_call_arguments.delta","delta":"{\"a\":"}`,
`{"type":"response.function_call_arguments.delta","delta":"1}"}`,
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`)
chunks = append(chunks, b.Finish()...)
got := chatChunkShapes(chunks)
want := "role+text,text,tool,tool,tool,finish:tool_calls,usage"
if got != want {
t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want)
}
if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" {
t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta)
}
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" {
t.Fatalf("工具首块 = %+v", tool)
}
args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments +
chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments
if args != `{"a":1}` {
t.Fatalf("实参增量聚合 = %s", args)
}
last := chunks[len(chunks)-1]
if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 {
t.Fatalf("usage 块 = %+v", last)
}
if b.Usage().TotalTokens != 12 {
t.Fatalf("Usage() = %+v", b.Usage())
}
}
// TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。
func TestChatRespBridgeVariants(t *testing.T) {
noUsage := NewChatRespBridge("c", "m", 1, false)
noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`))
noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`))
if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" {
t.Errorf("截断且不带 usage 块: %s", got)
}
empty := NewChatRespBridge("c", "m", 1, false)
fin := empty.Finish()
if got := chatChunkShapes(fin); got != "finish:stop" {
t.Errorf("空流收尾 = %s", got)
}
if fin[0].Choices[0].Delta.Role != "assistant" {
t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta)
}
}
+121 -24
View File
@@ -57,9 +57,6 @@ type LogEventService struct {
relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认
relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认
alertMu sync.Mutex // 保护告警规则冷却表
alertSentAt map[uint]time.Time // 规则 ID → 上次告警时刻(阈值型规则冷却)
}
// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。
@@ -373,20 +370,18 @@ func (s *LogEventService) parseOnce(ctx context.Context) {
if len(events) == 0 {
return
}
rules := s.loadEnabledAlertRules(ctx)
for i := range events {
s.processLogEvent(ctx, &events[i], rules)
s.processLogEvent(ctx, &events[i])
}
}
// processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent, rules []model.AlertRule) {
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent) {
parsed := parseLogEvent([]byte(event.Payload))
if !s.updateParsedEvent(ctx, event, parsed) {
return
}
s.notifyCritical(ctx, event, parsed)
s.matchAlertRules(ctx, rules, event, parsed)
}
// updateParsedEvent 用条件 UPDATE 禁止 Save 在删除后隐式重建事件。
@@ -413,26 +408,49 @@ func (s *LogEventService) updateParsedEvent(ctx context.Context, event *model.Lo
// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段;
// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。
type onsEnvelope struct {
EventType string `json:"eventType"`
Type string `json:"type"`
Source string `json:"source"`
EventTime string `json:"eventTime"`
ResourceName string `json:"resourceName"`
Data json.RawMessage `json:"data"`
Identity *onsIdentity `json:"identity"`
EventType string `json:"eventType"`
Type string `json:"type"`
Source string `json:"source"`
EventTime string `json:"eventTime"`
ResourceName string `json:"resourceName"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Identity *onsIdentity `json:"identity"`
AdditionalDetails *onsAddDetails `json:"additionalDetails"`
StateChange *onsStateChange `json:"stateChange"`
}
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
type onsIdentity struct {
IPAddress string `json:"ipAddress"`
IPAddress string `json:"ipAddress"`
PrincipalName string `json:"principalName"`
}
// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。
// onsAddDetails 是 IDCS 登录类事件 data.additionalDetails 的补充字段;
// AuditEventMapValue 为嵌套的 JSON 字符串(含 eventId 成败与失败原因)。
type onsAddDetails struct {
ActorName string `json:"actorName"`
ClientIP string `json:"clientIp"`
AuditEventMapValue string `json:"auditEventMapValue"`
}
// onsStateChange 承载 Audit v2 的资源变更快照;description 供策略类事件推送文案。
type onsStateChange struct {
Current struct {
Description string `json:"description"`
} `json:"current"`
}
// parsedEvent 是从消息原文提取的展示字段集;ResourceName/Actor/Outcome/Detail
// 仅供 P2 推送文案,不落库。
type parsedEvent struct {
EventType string
Source string
SourceIP string
ResourceName string
Actor string // 操作者(identity.principalName,登录事件回退 actorName)
Outcome string // 成功 / 失败 / 空(判读不出)
Detail string // 补充说明(策略描述、登录失败原因等)
EventTime *time.Time
}
@@ -455,7 +473,7 @@ func parseLogEvent(payload []byte) parsedEvent {
return envelopeFields(env)
}
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
if outer.EventType == "" {
outer.EventType = inner.EventType
@@ -472,13 +490,22 @@ func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
if outer.ResourceName == "" {
outer.ResourceName = inner.ResourceName
}
if outer.Message == "" {
outer.Message = inner.Message
}
if outer.Identity == nil {
outer.Identity = inner.Identity
}
if outer.AdditionalDetails == nil {
outer.AdditionalDetails = inner.AdditionalDetails
}
if outer.StateChange == nil {
outer.StateChange = inner.StateChange
}
return outer
}
// envelopeFields 收敛字段别名并解析事件时间。
// envelopeFields 收敛字段别名并解析事件时间与推送用补充字段
func envelopeFields(env onsEnvelope) parsedEvent {
eventType := env.EventType
if eventType == "" {
@@ -490,19 +517,81 @@ func envelopeFields(env onsEnvelope) parsedEvent {
eventTime = &ts
}
}
ip := ""
if env.Identity != nil {
ip = env.Identity.IPAddress
}
outcome, detail := envOutcome(env)
return parsedEvent{
EventType: clip(eventType, 128),
Source: clip(env.Source, 64),
SourceIP: clip(ip, 64),
SourceIP: clip(envIP(env), 64),
ResourceName: clip(env.ResourceName, 128),
Actor: clipRunes(envActor(env), 64),
Outcome: outcome,
Detail: clipRunes(detail, 200),
EventTime: eventTime,
}
}
// envIP 取发起方 IP:Audit 的 identity.ipAddress,登录事件回退 additionalDetails.clientIp。
func envIP(env onsEnvelope) string {
if env.Identity != nil && env.Identity.IPAddress != "" {
return env.Identity.IPAddress
}
if env.AdditionalDetails != nil {
return env.AdditionalDetails.ClientIP
}
return ""
}
// envActor 取操作者:Audit 的 identity.principalName,登录事件回退 additionalDetails.actorName。
func envActor(env onsEnvelope) string {
if env.Identity != nil && env.Identity.PrincipalName != "" {
return env.Identity.PrincipalName
}
if env.AdditionalDetails != nil {
return env.AdditionalDetails.ActorName
}
return ""
}
// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余
// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。
func envOutcome(env onsEnvelope) (outcome, detail string) {
if env.StateChange != nil {
detail = env.StateChange.Current.Description
}
if env.AdditionalDetails != nil && env.AdditionalDetails.AuditEventMapValue != "" {
return ssoOutcome(env.AdditionalDetails.AuditEventMapValue, detail)
}
switch {
case strings.HasSuffix(env.Message, " succeeded"):
outcome = "成功"
case strings.HasSuffix(env.Message, " failed"):
outcome = "失败"
}
return outcome, detail
}
// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败,
// 失败时以其 message 作为原因说明。
func ssoOutcome(raw, detail string) (string, string) {
var ev struct {
EventID string `json:"eventId"`
Message string `json:"message"`
}
if json.Unmarshal([]byte(raw), &ev) != nil {
return "", detail
}
switch {
case strings.Contains(ev.EventID, "success"):
return "成功", detail
case strings.Contains(ev.EventID, "failure"):
if ev.Message != "" {
detail = ev.Message
}
return "失败", detail
}
return "", detail
}
// clip 按模型列宽截断解析出的字段。
func clip(s string, max int) string {
if len(s) > max {
@@ -511,6 +600,15 @@ func clip(s string, max int) string {
return s
}
// clipRunes 按字符数截断(通知文案用,中文安全)。
func clipRunes(s string, max int) string {
r := []rune(s)
if len(r) > max {
return string(r[:max])
}
return s
}
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
func (s *LogEventService) StartCleanup(ctx context.Context) {
s.wg.Add(1)
@@ -535,7 +633,6 @@ func (s *LogEventService) cleanupOnce(ctx context.Context) {
if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil {
log.Printf("log event cleanup: %v", err)
}
s.cleanupAlertHits(ctx)
}
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
+37 -8
View File
@@ -30,8 +30,7 @@ func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{},
&model.AlertRule{}, &model.AlertRuleHit{}); err != nil {
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cfg := model.OciConfig{Alias: "测试租户"}
@@ -240,12 +239,15 @@ func TestIngestRejectsUnknownConfig(t *testing.T) {
func TestParseLogEvent(t *testing.T) {
ts := "2026-07-07T08:00:00Z"
tests := []struct {
name string
payload string
wantType string
wantSource string
wantIP string
wantTime bool
name string
payload string
wantType string
wantSource string
wantIP string
wantTime bool
wantActor string
wantOutcome string
wantDetail string
}{
{
name: "CloudEvents 单事件",
@@ -283,6 +285,29 @@ func TestParseLogEvent(t *testing.T) {
payload: `{"eventType":"x","data":{"identity":{}}}`,
wantType: "x",
},
{
name: "Audit v2 提取操作者成败与策略描述",
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
"message":"ociportal-logs-sch CreatePolicy succeeded",
"stateChange":{"current":{"description":"允许 Connector 发布到 ONS"}}}}`,
wantType: "com.oraclecloud.identityControlPlane.CreatePolicy", wantIP: "129.159.43.9",
wantActor: "Alfonso Garcia", wantOutcome: "成功", wantDetail: "允许 Connector 发布到 ONS",
},
{
name: "IDCS 登录失败提取用户 IP 与原因",
payload: `{"eventType":"com.oraclecloud.IdentitySignOn.InteractiveLogin","data":{
"additionalDetails":{"actorName":"oci","clientIp":"155.117.82.111",
"auditEventMapValue":"{\"eventId\":\"sso.authentication.failure\",\"message\":\"Authentication failure : incorrect password.\"}"}}}`,
wantType: "com.oraclecloud.IdentitySignOn.InteractiveLogin", wantIP: "155.117.82.111",
wantActor: "oci", wantOutcome: "失败", wantDetail: "Authentication failure : incorrect password.",
},
{
name: "message failed 后缀判失败",
payload: `{"eventType":"x","data":{"message":"vm TerminateInstance failed"}}`,
wantType: "x",
wantOutcome: "失败",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -291,6 +316,10 @@ func TestParseLogEvent(t *testing.T) {
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
}
if got.Actor != tt.wantActor || got.Outcome != tt.wantOutcome || got.Detail != tt.wantDetail {
t.Errorf("推送字段 = (%q,%q,%q), want (%q,%q,%q)",
got.Actor, got.Outcome, got.Detail, tt.wantActor, tt.wantOutcome, tt.wantDetail)
}
if (got.EventTime != nil) != tt.wantTime {
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
}
+23 -1
View File
@@ -322,12 +322,34 @@ func relayEventShortName(eventType string) string {
}
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
name := relayEventShortName(p.EventType)
if name == "" || !relayCriticalSet[name] {
return nil, false
}
return map[string]string{"tenant": alias, "event": name, "resource": p.ResourceName}, true
return map[string]string{
"tenant": alias, "event": name,
"resource": orDash(p.ResourceName), "actor": orDash(p.Actor),
"ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome),
"detail": detailLine(p.Detail),
}, true
}
// orDash 空值兜底为 —,避免模板出现悬空标签。
func orDash(v string) string {
if v == "" {
return "—"
}
return v
}
// detailLine 把补充说明包装为独立行;为空时不产生多余空行。
func detailLine(v string) string {
if v == "" {
return ""
}
return "\n" + v
}
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
+32 -20
View File
@@ -313,26 +313,36 @@ func TestRelayReady(t *testing.T) {
func TestCriticalEventText(t *testing.T) {
tests := []struct {
name string
event parsedEvent
wantOK bool
wantText string
name string
event parsedEvent
wantOK bool
want map[string]string
}{
{
name: "实例终止命中",
event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", ResourceName: "vm-1"},
wantOK: true,
wantText: "☁️ 云端事件:免费01 TerminateInstance vm-1",
name: "实例终止含操作者与成败",
event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
ResourceName: "vm-1", Actor: "demo@example.com", SourceIP: "203.0.113.8", Outcome: "成功"},
wantOK: true,
want: map[string]string{"event": "TerminateInstance", "resource": "vm-1",
"actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
},
{
name: "无资源名省略尾段",
event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"},
wantOK: true,
wantText: "☁️ 云端事件:免费01 CreateApiKey",
name: "字段缺失兜底破折号",
event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"},
wantOK: true,
want: map[string]string{"event": "CreateApiKey", "resource": "—",
"actor": "—", "ip": "—", "outcome": "—", "detail": ""},
},
{
name: "补充说明独立成行",
event: parsedEvent{EventType: "CreatePolicy", Detail: "允许发布到 ONS Topic"},
wantOK: true,
want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"},
},
{name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true, wantText: "☁️ 云端事件:免费01 LaunchInstance"},
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true,
want: map[string]string{"event": "LaunchInstance"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -340,13 +350,15 @@ func TestCriticalEventText(t *testing.T) {
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok {
got := "☁️ 云端事件:" + vars["tenant"] + " " + vars["event"]
if vars["resource"] != "" {
got += " " + vars["resource"]
}
if got != tt.wantText {
t.Errorf("vars 渲染 = %q, want %q", got, tt.wantText)
if !ok {
return
}
if vars["tenant"] != "免费01" {
t.Errorf("tenant = %q, want 免费01", vars["tenant"])
}
for k, want := range tt.want {
if vars[k] != want {
t.Errorf("%s = %q, want %q", k, vars[k], want)
}
}
})
+22 -23
View File
@@ -21,7 +21,6 @@ var notifyTplOrder = []string{
"task_fail", "task_recover", "snatch_success", "tenant_dead", "task_stop",
"login_lock", "model_deprecated",
"log_event_instance", "log_event_identity", "log_event_policy", "log_event_region", "log_event_login",
"audit_alert",
}
var notifyTplDefs = map[string]notifyTplDef{
@@ -73,40 +72,40 @@ var notifyTplDefs = map[string]notifyTplDef{
},
"log_event_instance": {
Label: "实例生命周期",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1"},
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1",
"actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
},
"log_event_identity": {
Label: "用户与凭据",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo"},
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo",
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
},
"log_event_policy": {
Label: "策略变更",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "UpdatePolicy", "resource": "admin-policy"},
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
Sample: map[string]string{"tenant": "免费01", "event": "CreatePolicy", "resource": "admin-policy",
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功",
"detail": "\n允许 Service Connector 发布到 ONS Topic"},
},
"log_event_region": {
Label: "区域订阅",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1"},
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1",
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
},
"log_event_login": {
Label: "控制台登录",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "demo@example.com"},
},
"audit_alert": {
Label: "审计告警",
Default: "🔔 审计告警:{{rule}}\n租户 {{tenant}} · {{event}} {{resource}}\n来源 {{ip}} · 窗口内 {{count}} 次",
Vars: []string{"rule", "tenant", "event", "resource", "ip", "count"},
Sample: map[string]string{"rule": "非白名单终止实例", "tenant": "免费01",
"event": "TerminateInstance", "resource": "web-server-1", "ip": "203.0.113.8", "count": "1"},
Default: "☁️ 云端事件:{{tenant}} {{event}} · {{outcome}}\n用户 {{actor}} · 登录 IP {{ip}}{{detail}}",
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "—",
"actor": "demo@example.com", "ip": "155.117.82.111", "outcome": "失败",
"detail": "\nAuthentication failure : You entered an incorrect user name or password."},
},
}
+6 -8
View File
@@ -16,11 +16,10 @@ import (
// OciConfigService 管理 API Key 配置的导入、测活与快照同步。
type OciConfigService struct {
db *gorm.DB
cipher *crypto.Cipher
client oci.Client
cleanupTasks *TaskService
cleanupEvents *LogEventService
db *gorm.DB
cipher *crypto.Cipher
client oci.Client
cleanupTasks *TaskService
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache
@@ -31,10 +30,9 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
}
// SetTenantCleanupDeps 注入租户删除提交后的任务与告警内存状态同步依赖。
func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService, events *LogEventService) {
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService) {
s.cleanupTasks = tasks
s.cleanupEvents = events
}
// ImportInput 是导入一份 API Key 的输入:
+6 -87
View File
@@ -16,7 +16,6 @@ import (
type tenantDeleteResult struct {
config model.OciConfig
deletedTaskIDs []uint
alertRuleIDs []uint
channelsGone bool
}
@@ -48,7 +47,7 @@ func (s *OciConfigService) deleteTenantInTx(tx *gorm.DB, id uint, result *tenant
if err := s.deleteTenantTasks(tx, id, result); err != nil {
return err
}
if err := deleteTenantEvents(tx, id, result); err != nil {
if err := deleteTenantEvents(tx, id); err != nil {
return err
}
if err := deleteTenantAI(tx, id, result); err != nil {
@@ -205,17 +204,9 @@ func resetTenantTask(tx *gorm.DB, action tenantTaskAction) error {
return nil
}
func deleteTenantEvents(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
ruleIDs, affectedRules, err := loadTenantEventRefs(tx, id)
if err != nil {
return err
}
if err := deleteAlertHits(tx, id); err != nil {
return err
}
result.alertRuleIDs = mergeIDs(ruleIDs, affectedRules)
if err := deleteWhere(tx, &model.AlertRule{}, "oci_config_id = ?", id); err != nil {
return fmt.Errorf("delete tenant alert rules: %w", err)
func deleteTenantEvents(tx *gorm.DB, id uint) error {
if err := lockTenantEventRows(tx, id); err != nil {
return fmt.Errorf("load tenant log events: %w", err)
}
if err := deleteWhere(tx, &model.LogEvent{}, "oci_config_id = ?", id); err != nil {
return fmt.Errorf("delete tenant log events: %w", err)
@@ -223,84 +214,15 @@ func deleteTenantEvents(tx *gorm.DB, id uint, result *tenantDeleteResult) error
return nil
}
func loadTenantEventRefs(tx *gorm.DB, id uint) ([]uint, []uint, error) {
ruleIDs, err := lockedTenantRuleIDs(tx, id)
if err != nil {
return nil, nil, fmt.Errorf("load tenant alert rules: %w", err)
}
if err := lockTenantEventRows(tx, id); err != nil {
return nil, nil, fmt.Errorf("load tenant log events: %w", err)
}
affected, err := alertHitRuleIDs(tx, id)
if err != nil {
return nil, nil, err
}
return ruleIDs, affected, nil
}
func lockedTenantRuleIDs(tx *gorm.DB, id uint) ([]uint, error) {
var rows []model.AlertRule
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("oci_config_id = ?", id).Order("id").Find(&rows).Error
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids, err
}
// lockTenantEventRows 对租户全部日志事件行加 FOR UPDATE 锁(SQLite 忽略,
// MySQL/PG 阻塞并发命中写入);事件可达数万,ID 不回传拼接 SQL,
// 后续删除与关联查询一律用子查询,避免绑定变量上限。
// MySQL/PG 阻塞并发写入);事件可达数万,ID 不回传拼接 SQL,删除用条件语句,
// 避免绑定变量上限。
func lockTenantEventRows(tx *gorm.DB, id uint) error {
var rows []model.LogEvent
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("oci_config_id = ?", id).Order("id").Find(&rows).Error
}
// tenantEventIDs 构造「本租户日志事件 ID」子查询,供 IN (?) 内联。
func tenantEventIDs(tx *gorm.DB, id uint) *gorm.DB {
return tx.Model(&model.LogEvent{}).Select("id").Where("oci_config_id = ?", id)
}
// alertHitRuleIDs 找出命中引用了本租户事件的规则 ID(去重,可能含他租户/全局规则)。
func alertHitRuleIDs(tx *gorm.DB, id uint) ([]uint, error) {
ids := make([]uint, 0)
err := tx.Model(&model.AlertRuleHit{}).Where("log_event_id IN (?)", tenantEventIDs(tx, id)).
Distinct().Pluck("rule_id", &ids).Error
if err != nil {
return nil, fmt.Errorf("load affected alert rules: %w", err)
}
return ids, nil
}
func mergeIDs(groups ...[]uint) []uint {
seen := make(map[uint]struct{})
out := make([]uint, 0)
for _, ids := range groups {
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
// deleteAlertHits 删除本租户相关的全部命中:规则属于本租户,或命中引用了
// 本租户的日志事件(他租户/全局规则命中本租户事件的行一并清)。
func deleteAlertHits(tx *gorm.DB, id uint) error {
rules := tx.Model(&model.AlertRule{}).Select("id").Where("oci_config_id = ?", id)
err := tx.Where("rule_id IN (?) OR log_event_id IN (?)", rules, tenantEventIDs(tx, id)).
Delete(&model.AlertRuleHit{}).Error
if err != nil {
return fmt.Errorf("delete tenant alert hits: %w", err)
}
return nil
}
func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
channelIDs, err := lockedAiChannelIDs(tx, id)
if err != nil {
@@ -451,9 +373,6 @@ func (s *OciConfigService) afterTenantDelete(ctx context.Context, result *tenant
if client, ok := s.client.(tenancyCacheInvalidator); ok {
client.InvalidateTenancy(result.config.TenancyOCID)
}
if s.cleanupEvents != nil {
s.cleanupEvents.ClearAlertCooldown(result.alertRuleIDs)
}
if s.cleanupTasks != nil {
s.cleanupTasks.ApplyTenantCleanup(ctx, result.deletedTaskIDs, result.channelsGone)
}
+4 -65
View File
@@ -55,7 +55,7 @@ func newTenantDeleteEnv(t *testing.T, client oci.Client) (*OciConfigService, *Ta
}
configs := NewOciConfigService(db, cipher, client)
tasks := NewTaskService(db, configs, nil, nil)
configs.SetTenantCleanupDeps(tasks, nil)
configs.SetTenantCleanupDeps(tasks)
return configs, tasks, db
}
@@ -64,7 +64,7 @@ func migrateTenantDeleteModels(t *testing.T, db *gorm.DB) {
err := db.AutoMigrate(
&model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{},
&model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{},
&model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{},
&model.LogEvent{},
&model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}, &model.AiContentLog{},
&model.Proxy{}, &model.AiKey{}, &model.SystemLog{},
)
@@ -162,19 +162,9 @@ func seedTenantEvents(t *testing.T, db *gorm.DB, target, other uint) {
t.Helper()
targetEvent := model.LogEvent{OciConfigID: target, MessageID: "target-event"}
otherEvent := model.LogEvent{OciConfigID: other, MessageID: "other-event"}
targetRule := model.AlertRule{Name: "target-rule", OciConfigID: target}
globalRule := model.AlertRule{Name: "global-rule", OciConfigID: 0}
for _, value := range []any{&targetEvent, &otherEvent, &targetRule, &globalRule} {
for _, value := range []any{&targetEvent, &otherEvent} {
mustCreate(t, db, value)
}
hits := []model.AlertRuleHit{
{RuleID: targetRule.ID, LogEventID: otherEvent.ID},
{RuleID: globalRule.ID, LogEventID: targetEvent.ID},
{RuleID: globalRule.ID, LogEventID: otherEvent.ID},
}
for i := range hits {
mustCreate(t, db, &hits[i])
}
}
func seedTenantAI(t *testing.T, db *gorm.DB, target, other uint) {
@@ -202,7 +192,7 @@ func assertTenantRowsGone(t *testing.T, db *gorm.DB, id uint) {
rows := []any{
&model.OciConfig{}, &model.CheckSnapshot{}, &model.CostSnapshot{},
&model.RegionCache{}, &model.CompartmentCache{}, &model.LogEvent{},
&model.AlertRule{}, &model.AiChannel{},
&model.AiChannel{},
}
for _, value := range rows {
column := "oci_config_id"
@@ -212,7 +202,6 @@ func assertTenantRowsGone(t *testing.T, db *gorm.DB, id uint) {
assertCount(t, db, value, column+" = ?", []any{id}, 0)
}
assertCount(t, db, &model.Setting{}, "key = ?", []any{secretKey(id)}, 0)
assertCount(t, db, &model.AlertRuleHit{}, "", nil, 1)
assertCount(t, db, &model.AiModelCache{}, "", nil, 1)
assertCount(t, db, &model.AiCallLog{}, "", nil, 1)
assertCount(t, db, &model.AiContentLog{}, "", nil, 1)
@@ -248,31 +237,10 @@ func assertRemainingIndirectRows(t *testing.T, db *gorm.DB, otherID uint) {
t.Fatalf("load other AI call: %v", err)
}
assertCount(t, db, &model.AiContentLog{}, "call_log_id = ?", []any{call.ID}, 1)
assertRemainingAlertHit(t, db, otherID)
}
func assertRemainingAlertHit(t *testing.T, db *gorm.DB, otherID uint) {
t.Helper()
var hit model.AlertRuleHit
if err := db.First(&hit).Error; err != nil {
t.Fatalf("load remaining alert hit: %v", err)
}
var rule model.AlertRule
var event model.LogEvent
if err := db.First(&rule, hit.RuleID).Error; err != nil {
t.Fatalf("load remaining rule: %v", err)
}
if err := db.First(&event, hit.LogEventID).Error; err != nil {
t.Fatalf("load remaining event: %v", err)
}
if rule.OciConfigID != 0 || event.OciConfigID != otherID {
t.Errorf("remaining hit = rule cfg %d/event cfg %d, want global/other", rule.OciConfigID, event.OciConfigID)
}
}
func assertRetainedGlobals(t *testing.T, db *gorm.DB) {
t.Helper()
assertCount(t, db, &model.AlertRule{}, "oci_config_id = 0", nil, 1)
assertCount(t, db, &model.Proxy{}, "", nil, 1)
assertCount(t, db, &model.AiKey{}, "", nil, 1)
assertCount(t, db, &model.SystemLog{}, "", nil, 1)
@@ -629,35 +597,6 @@ func TestDeleteTenantSkipsCorruptTaskPayload(t *testing.T) {
assertCount(t, db, &model.OciConfig{}, "id = ?", []any{target.ID}, 0)
}
func TestDeleteTenantEventsAffectedRules(t *testing.T) {
_, _, db := newTenantDeleteEnv(t, &fakeClient{})
target, other := seedDeleteTenants(t, db)
seedTenantEvents(t, db, target.ID, other.ID)
// 规则 ID 须在删除前取:target-rule 会随租户一并删除
var targetRule, globalRule model.AlertRule
db.Where("name = ?", "target-rule").First(&targetRule)
db.Where("name = ?", "global-rule").First(&globalRule)
result := &tenantDeleteResult{}
err := db.Transaction(func(tx *gorm.DB) error {
return deleteTenantEvents(tx, target.ID, result)
})
if err != nil {
t.Fatalf("deleteTenantEvents: %v", err)
}
// target-rule 属本租户,global-rule 的命中引用了本租户事件:都应回收冷却
got := map[uint]bool{}
for _, id := range result.alertRuleIDs {
got[id] = true
}
if len(got) != 2 || !got[targetRule.ID] || !got[globalRule.ID] {
t.Errorf("alertRuleIDs = %v, want 含 target-rule(%d) 与 global-rule(%d)", result.alertRuleIDs, targetRule.ID, globalRule.ID)
}
// 仅保留 global-rule × other-event 一条命中
assertCount(t, db, &model.AlertRuleHit{}, "", nil, 1)
}
func TestDeleteTenantManyEventsNoVarLimit(t *testing.T) {
// 回归:事件数超 SQLite 绑定变量上限(32766)时删除仍成功(旧实现 IN 展开必失败)
configs, _, db := newTenantDeleteEnv(t, &fakeClient{})