package service import ( "context" "encoding/json" "errors" "time" "oci-portal/internal/oci" ) // 审计查询时间窗(小时)上下限。 const ( minAuditHours = 1 maxAuditHours = 72 ) // 审计原始事件缓存:约 3KB/条,上限 2000 条 ≈ 6MB;TTL 内详情秒开。 const ( auditRawMax = 2000 auditRawTTL = 10 * time.Minute ) // ErrInvalidAuditHours 表示审计时间窗参数越界,handler 据此返回 400。 var ErrInvalidAuditHours = errors.New("audit events: hours must be between 1 and 72") // ErrInvalidAuditWindow 表示续查的绝对时间窗非法(格式/顺序/跨度),handler 返回 400。 var ErrInvalidAuditWindow = errors.New("audit events: invalid start/end window") // ErrAuditEventGone 表示原始事件缓存过期且小窗重查未找回;handler 映射 404。 var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试") // AuditQuery 是审计查询参数:Hours 为首查(相对当前时刻); // Start/End(RFC3339)为续查绝对窗,配合 Page 从截断游标断点续翻。 type AuditQuery struct { Region string Hours int Start string End string Page string } // AuditEventsView 是审计查询响应:列表不含 raw(详情接口取回); // Start/End 回传本次实际使用的绝对窗,截断续查必须原样带回(游标绑定查询参数)。 type AuditEventsView struct { Items []oci.AuditEvent `json:"items"` Truncated bool `json:"truncated"` NextPage string `json:"nextPage,omitempty"` Start time.Time `json:"start"` End time.Time `json:"end"` } // AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置 // 默认区域。原始事件剥离进进程内缓存,响应体瘦身约 10 倍(调研④方案 A1+B1)。 func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuery) (AuditEventsView, error) { start, end, err := auditWindow(q) if err != nil { return AuditEventsView{}, err } cred, err := s.credentialsByID(ctx, id) if err != nil { return AuditEventsView{}, err } res, err := s.client.ListAuditEvents(ctx, cred, q.Region, start, end, q.Page) if err != nil { return AuditEventsView{}, err } return AuditEventsView{ Items: s.stripAuditRaw(res.Items), Truncated: res.Truncated, NextPage: res.NextPage, // 与实际请求同粒度(分钟),续查回传时窗口逐字节一致 Start: start.UTC().Truncate(time.Minute), End: end.UTC().Truncate(time.Minute), }, nil } // auditWindow 解析查询窗口:带 start/end/page 走绝对窗校验,否则按 hours 相对窗。 func auditWindow(q AuditQuery) (time.Time, time.Time, error) { if q.Start != "" || q.End != "" || q.Page != "" { start, err1 := time.Parse(time.RFC3339, q.Start) end, err2 := time.Parse(time.RFC3339, q.End) if err1 != nil || err2 != nil || !start.Before(end) || end.Sub(start) > maxAuditHours*time.Hour+time.Minute { return time.Time{}, time.Time{}, ErrInvalidAuditWindow } return start, end, nil } if q.Hours < minAuditHours || q.Hours > maxAuditHours { return time.Time{}, time.Time{}, ErrInvalidAuditHours } end := time.Now().UTC() return end.Add(-time.Duration(q.Hours) * time.Hour), end, nil } // stripAuditRaw 把每条原始事件按 eventId 放进缓存并从列表剥离。 func (s *OciConfigService) stripAuditRaw(items []oci.AuditEvent) []oci.AuditEvent { for i := range items { if items[i].EventId != "" && items[i].Raw != nil { s.auditRaw.Set(items[i].EventId, items[i].Raw, auditRawTTL) } items[i].Raw = nil } return items } // AuditEventDetail 取回单条事件的原始 JSON:缓存命中即回;miss 按事件时间 // 所在分钟起 2 分钟小窗重查兜底(调研④方案 A2),仍未命中报 ErrAuditEventGone。 func (s *OciConfigService) AuditEventDetail(ctx context.Context, id uint, region, eventID string, eventTime time.Time) (json.RawMessage, error) { if raw, ok := s.auditRaw.Get(eventID); ok { return raw.(json.RawMessage), nil } cred, err := s.credentialsByID(ctx, id) if err != nil { return nil, err } start := eventTime.UTC().Truncate(time.Minute) res, err := s.client.ListAuditEvents(ctx, cred, region, start, start.Add(2*time.Minute), "") if err != nil { return nil, err } var found json.RawMessage for _, ev := range res.Items { if ev.EventId == "" || ev.Raw == nil { continue } s.auditRaw.Set(ev.EventId, ev.Raw, auditRawTTL) if ev.EventId == eventID { found = ev.Raw } } if found == nil { return nil, ErrAuditEventGone } return found, nil }