初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。
// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。
func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) {
ir := aiwire.ChatRequest{
Model: req.Model,
Temperature: req.Temperature,
TopP: req.TopP,
TopK: req.TopK,
Stop: req.StopSequences,
Stream: req.Stream,
Tools: anthTools(req.Tools),
ToolChoice: anthToolChoice(req.ToolChoice),
}
if req.MaxTokens > 0 {
mt := req.MaxTokens
ir.MaxTokens = &mt
}
if sys := req.SystemText(); sys != "" {
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)})
}
for _, m := range req.Messages {
msgs, err := anthMessageToIR(m)
if err != nil {
return ir, err
}
ir.Messages = append(ir.Messages, msgs...)
}
return ir, nil
}
// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息,
// image 块转为 IR image_url 部件。
func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) {
var out []aiwire.ChatMessage
var parts []aiwire.ContentPart
var toolCalls []aiwire.ToolCall
hasImage := false
for _, b := range m.Content.AllBlocks() {
switch b.Type {
case "text":
parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text})
case "image":
p, err := anthImagePart(b.Source)
if err != nil {
return nil, err
}
parts, hasImage = append(parts, p), true
case "tool_use":
toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function",
Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}})
case "tool_result":
out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID,
Content: aiwire.NewTextContent(b.ResultText())})
case "thinking", "redacted_thinking":
// 一期忽略 thinking 块
default:
return nil, ErrAiUnsupportedBlock
}
}
content := partsContent(parts, hasImage)
if hasImage || content.JoinText() != "" || len(toolCalls) > 0 {
out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls})
}
return out, nil
}
// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。
func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) {
var src struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
URL string `json:"url"`
}
if err := json.Unmarshal(source, &src); err != nil {
return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err)
}
switch src.Type {
case "base64":
if src.MediaType == "" || src.Data == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data")
}
url := "data:" + src.MediaType + ";base64," + src.Data
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil
case "url":
if src.URL == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url")
}
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil
}
return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
}
// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。
func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content {
if !hasImage {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p.Text)
}
return aiwire.NewTextContent(sb.String())
}
return aiwire.NewPartsContent(parts)
}
func anthTools(tools []aiwire.AnthTool) []aiwire.Tool {
if len(tools) == 0 {
return nil
}
out := make([]aiwire.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, aiwire.Tool{Type: "function", Function: aiwire.FunctionDef{
Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
}
return out
}
// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。
func anthToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &tc) != nil {
return nil
}
switch tc.Type {
case "auto":
return json.RawMessage(`"auto"`)
case "any":
return json.RawMessage(`"required"`)
case "tool":
b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}})
return b
case "none":
return json.RawMessage(`"none"`)
}
return nil
}
// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。
func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse {
out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if text := choice.Message.Content.JoinText(); text != "" {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text})
}
for _, tc := range choice.Message.ToolCalls {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID,
Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)})
}
out.StopReason = anthStopReason(choice.FinishReason)
}
if resp.Usage != nil {
out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens,
CacheReadInputTokens: resp.Usage.CachedTokens()}
}
return out
}
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
func argsToJSON(args string) json.RawMessage {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(map[string]string{"_raw": args})
return b
}
func anthStopReason(finish string) string {
switch finish {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
default:
return "end_turn"
}
}
// ---- Anthropic 流式状态机 ----
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
type AnthEvent struct {
Event string
Data any
}
// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列:
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
type AnthStream struct {
id, model string
started bool
blockOpen bool
blockIsTool bool
toolID string
blockIndex int
stopReason string
usage aiwire.AnthUsage
}
// NewAnthStream 构造状态机;id 为响应消息 ID。
func NewAnthStream(id, model string) *AnthStream {
return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
}
// Feed 消费一个 IR chunk,返回应立即写出的事件。
func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent {
var events []AnthEvent
if !st.started {
st.started = true
events = append(events, st.startEvent())
}
if chunk.Usage != nil {
st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens,
CacheReadInputTokens: chunk.Usage.CachedTokens()}
}
for _, choice := range chunk.Choices {
events = append(events, st.feedDelta(choice.Delta)...)
if choice.FinishReason != nil && *choice.FinishReason != "" {
st.stopReason = anthStopReason(*choice.FinishReason)
}
}
return events
}
func (st *AnthStream) startEvent() AnthEvent {
return AnthEvent{Event: "message_start", Data: map[string]any{
"type": "message_start",
"message": map[string]any{
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
"content": []any{}, "stop_reason": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
},
}}
}
// feedDelta 处理文本与工具调用增量,必要时切块。
func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent {
var events []AnthEvent
if d.Content != "" {
if !st.blockOpen || st.blockIsTool {
events = append(events, st.openBlock(false, "", "")...)
}
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "text_delta", "text": d.Content},
}})
}
for _, tc := range d.ToolCalls {
if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) {
events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...)
}
if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool {
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments},
}})
}
}
return events
}
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
var events []AnthEvent
if st.blockOpen {
events = append(events, st.closeBlockEvent())
}
st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID
st.blockIndex++
block := map[string]any{"type": "text", "text": ""}
if isTool {
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
}
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
}})
return events
}
func (st *AnthStream) closeBlockEvent() AnthEvent {
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
"type": "content_block_stop", "index": st.blockIndex,
}}
}
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
func (st *AnthStream) Finish() []AnthEvent {
var events []AnthEvent
if !st.started {
events = append(events, st.startEvent())
st.started = true
}
if st.blockOpen {
events = append(events, st.closeBlockEvent())
st.blockOpen = false
}
events = append(events,
AnthEvent{Event: "message_delta", Data: map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
"usage": map[string]int{"output_tokens": st.usage.OutputTokens},
}},
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
)
return events
}
// Usage 返回聚合到的用量(供调用日志)。
func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage }
+136
View File
@@ -0,0 +1,136 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func mustAnthReq(t *testing.T, body string) aiwire.MessagesRequest {
t.Helper()
var req aiwire.MessagesRequest
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("unmarshal anthropic request: %v", err)
}
return req
}
func TestAnthropicToIR(t *testing.T) {
req := mustAnthReq(t, `{
"model": "meta.llama-3.3-70b-instruct", "max_tokens": 128, "system": "你是助手",
"messages": [
{"role": "user", "content": "东京天气?"},
{"role": "assistant", "content": [
{"type": "text", "text": "查询中"},
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
{"type": "text", "text": "继续"}
]}
],
"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "any"}
}`)
ir, err := AnthropicToIR(req)
if err != nil {
t.Fatalf("AnthropicToIR: %v", err)
}
roles := make([]string, 0, len(ir.Messages))
for _, m := range ir.Messages {
roles = append(roles, m.Role)
}
want := []string{"system", "user", "assistant", "tool", "user"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Fatalf("roles = %v, want %v", roles, want)
}
if ir.Messages[2].ToolCalls[0].Function.Name != "get_weather" {
t.Errorf("tool_use 未映射: %+v", ir.Messages[2])
}
if ir.Messages[3].ToolCallID != "t1" || ir.Messages[3].Content.JoinText() != "晴 25 度" {
t.Errorf("tool_result 未拆为 tool 消息: %+v", ir.Messages[3])
}
if ir.MaxTokens == nil || *ir.MaxTokens != 128 || len(ir.Tools) != 1 {
t.Errorf("max_tokens/tools 未直通")
}
if string(ir.ToolChoice) != `"required"` {
t.Errorf("tool_choice any → %s, want required", ir.ToolChoice)
}
// image 块拒绝
bad := mustAnthReq(t, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"image","source":{}}]}]}`)
if _, err := AnthropicToIR(bad); err == nil {
t.Error("image 块应被拒绝")
}
}
func TestIRRespToAnthropic(t *testing.T) {
resp := &aiwire.ChatResponse{
Model: "m1",
Choices: []aiwire.Choice{{
Message: aiwire.ChatMessage{
Role: "assistant",
Content: aiwire.NewTextContent("查到了"),
ToolCalls: []aiwire.ToolCall{{ID: "t1", Type: "function",
Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}},
},
FinishReason: "tool_calls",
}},
Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5},
}
out := IRRespToAnthropic(resp, "msg_1")
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[1].Type != "tool_use" {
t.Fatalf("content 块 = %+v", out.Content)
}
if out.StopReason != "tool_use" || out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 {
t.Errorf("stop_reason/usage 未映射: %+v", out)
}
if string(out.Content[1].Input) != `{"city":"东京"}` {
t.Errorf("tool input = %s", out.Content[1].Input)
}
}
// eventTypes 提取事件类型序列便于断言。
func eventTypes(events []AnthEvent) string {
types := make([]string, 0, len(events))
for _, e := range events {
types = append(types, e.Event)
}
return strings.Join(types, ",")
}
func TestAnthStreamTextAndTool(t *testing.T) {
st := NewAnthStream("msg_1", "m1")
fr := "tool_calls"
var events []AnthEvent
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "你"}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "好"}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Name: "get_weather", Arguments: `{"ci`}}},
}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{
Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Arguments: `ty":"东京"}`}}}}, FinishReason: &fr}},
Usage: &aiwire.Usage{PromptTokens: 8, CompletionTokens: 4},
})...)
events = append(events, st.Finish()...)
got := eventTypes(events)
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
"content_block_stop,content_block_start,content_block_delta,content_block_delta," +
"content_block_stop,message_delta,message_stop"
if got != want {
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
}
if st.Usage().OutputTokens != 4 {
t.Errorf("usage 未聚合: %+v", st.Usage())
}
}
func TestAnthStreamEmpty(t *testing.T) {
st := NewAnthStream("msg_1", "m1")
events := st.Finish()
if got := eventTypes(events); got != "message_start,message_delta,message_stop" {
t.Errorf("空流事件序列 = %s", got)
}
}
+641
View File
@@ -0,0 +1,641 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
const (
aiLogRetention = 90 * 24 * time.Hour
aiLogMaxRows = 50000
aiLogCleanupTick = 24 * time.Hour
// aiFailThreshold 起连续失败次数触发熔断,退避 2^(n-阈值) 分钟,封顶 30 分钟
aiFailThreshold = 5
aiBackoffCap = 30 * time.Minute
// aiKeyTouchGap 是 LastUsedAt 的最小写库间隔,避免高频调用刷库
aiKeyTouchGap = time.Minute
// 内容日志(红线例外)约束:开启必须限时(上限 7 天),正文截断,短保留
aiContentLogMaxHours = 168
aiContentLogRetention = 7 * 24 * time.Hour
aiContentLogMaxRows = 10000
aiContentBodyLimit = 64 * 1024
)
var (
// ErrAiKeyInvalid 表示网关密钥不存在或已禁用。
ErrAiKeyInvalid = errors.New("无效或已禁用的 API 密钥")
// ErrAiUnknownModel 表示没有任何渠道支持请求的模型。
ErrAiUnknownModel = errors.New("未知模型:没有渠道提供该模型")
// ErrAiNoChannel 表示模型有渠道支持但当前全部不可用(禁用/熔断)。
ErrAiNoChannel = errors.New("暂无可用渠道,请稍后重试")
)
// AiGatewayService 是 AI 网关核心:密钥、渠道号池、模型缓存与调用编排。
type AiGatewayService struct {
db *gorm.DB
configs *OciConfigService
client oci.Client
wg sync.WaitGroup
// touchMu 保护各密钥的最近触达时间(内存节流,不追求跨实例精确)
touchMu sync.Mutex
lastTouch map[uint]time.Time
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
onChannelsChanged func(context.Context)
}
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
}
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
s.onChannelsChanged = fn
}
func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
if s.onChannelsChanged != nil {
s.onChannelsChanged(ctx)
}
}
// ---- 网关密钥 ----
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
// group 非空时该密钥只在同分组渠道内路由。
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", nil, fmt.Errorf("密钥名称不能为空")
}
raw := strings.TrimSpace(customValue)
if raw == "" {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", nil, fmt.Errorf("generate key: %w", err)
}
raw = "sk-" + hex.EncodeToString(buf)
}
if len(raw) < 8 {
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
}
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true}
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
}
return raw, key, nil
}
func hashKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Keys 列出全部密钥(不含任何明文信息)。
func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
var keys []model.AiKey
err := s.db.WithContext(ctx).Order("id DESC").Find(&keys).Error
return keys, err
}
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
updates := map[string]any{}
if name = strings.TrimSpace(name); name != "" {
updates["name"] = name
}
if enabled != nil {
updates["enabled"] = *enabled
}
if group != nil {
updates["key_group"] = strings.TrimSpace(*group)
}
if len(updates) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteKey 删除密钥,立即使其失效。
func (s *AiGatewayService) DeleteKey(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.AiKey{}, id).Error
}
// VerifyKey 校验请求携带的密钥;通过后节流更新 LastUsedAt。
func (s *AiGatewayService) VerifyKey(ctx context.Context, raw string) (*model.AiKey, error) {
if raw == "" {
return nil, ErrAiKeyInvalid
}
var key model.AiKey
err := s.db.WithContext(ctx).Where("key_hash = ?", hashKey(raw)).First(&key).Error
if err != nil || !key.Enabled {
return nil, ErrAiKeyInvalid
}
s.touchKey(ctx, key.ID)
return &key, nil
}
// touchKey 更新最近使用时间,间隔小于 aiKeyTouchGap 时跳过写库。
func (s *AiGatewayService) touchKey(ctx context.Context, id uint) {
now := time.Now()
s.touchMu.Lock()
last, ok := s.lastTouch[id]
if ok && now.Sub(last) < aiKeyTouchGap {
s.touchMu.Unlock()
return
}
s.lastTouch[id] = now
s.touchMu.Unlock()
s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Update("last_used_at", now)
}
// ---- 渠道 ----
// ChannelInput 是创建 / 更新渠道的输入;Group 指针非空即覆盖分组(可置空)。
type ChannelInput struct {
OciConfigID uint `json:"ociConfigId"`
Region string `json:"region"`
Name string `json:"name"`
Group *string `json:"group"`
Enabled *bool `json:"enabled"`
Priority *int `json:"priority"`
Weight *int `json:"weight"`
}
// CreateChannel 新建渠道(租户×区域唯一);探测由前端随后显式触发。
func (s *AiGatewayService) CreateChannel(ctx context.Context, in ChannelInput) (*model.AiChannel, error) {
if in.OciConfigID == 0 || strings.TrimSpace(in.Region) == "" {
return nil, fmt.Errorf("渠道需要指定租户配置与区域")
}
cfg, err := s.configs.Get(ctx, in.OciConfigID)
if err != nil {
return nil, err
}
ch := &model.AiChannel{
Name: strings.TrimSpace(in.Name),
OciConfigID: in.OciConfigID,
Region: strings.TrimSpace(in.Region),
Enabled: true,
Priority: valueOr(in.Priority, 1),
Weight: valueOr(in.Weight, 1),
}
if in.Group != nil {
ch.Group = strings.TrimSpace(*in.Group)
}
if ch.Name == "" {
ch.Name = fmt.Sprintf("%s·%s", cfg.Alias, ch.Region)
}
if err := s.db.WithContext(ctx).Create(ch).Error; err != nil {
return nil, fmt.Errorf("该租户与区域的渠道已存在")
}
s.fireChannelsChanged(ctx)
return ch, nil
}
func valueOr(p *int, def int) int {
if p != nil {
return *p
}
return def
}
// Channels 列出全部渠道。
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
var chs []model.AiChannel
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
return chs, err
}
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
func (s *AiGatewayService) UpdateChannel(ctx context.Context, id uint, in ChannelInput) error {
updates := map[string]any{}
if name := strings.TrimSpace(in.Name); name != "" {
updates["name"] = name
}
if in.Group != nil {
updates["channel_group"] = strings.TrimSpace(*in.Group)
}
if in.Enabled != nil {
updates["enabled"] = *in.Enabled
}
if in.Priority != nil {
updates["priority"] = *in.Priority
}
if in.Weight != nil {
updates["weight"] = *in.Weight
}
if len(updates) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteChannel 删除渠道并清空其模型缓存。
func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&model.AiModelCache{}).Error; err != nil {
return err
}
return tx.Delete(&model.AiChannel{}, id).Error
})
if err == nil {
s.fireChannelsChanged(ctx)
}
return err
}
// ---- 探测与模型同步 ----
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return nil, fmt.Errorf("渠道不存在")
}
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
status, probeErr := s.probe(ctx, cred, &ch)
now := time.Now()
updates := map[string]any{"probe_status": status, "probe_error": probeErr, "last_probe_at": now}
if status == "ok" {
updates["fail_count"] = 0
updates["disabled_until"] = gorm.Expr("NULL")
}
if err := s.db.WithContext(ctx).Model(&ch).Updates(updates).Error; err != nil {
return nil, err
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.AiChannel
if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil {
return nil, err
}
return &fresh, nil
}
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
if err != nil {
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
}
if len(models) == 0 {
_ = s.replaceModels(ctx, ch.ID, nil)
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
}
if err := s.replaceModels(ctx, ch.ID, models); err != nil {
return "error", truncateErr(err.Error())
}
return s.probeChat(ctx, cred, ch, models)
}
// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际
// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
status, detail := "error", "无可试调对话模型"
for _, m := range probeCandidates(models) {
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
switch {
case code == 200 || code == 429:
return "ok", ""
case code == 401 || code == 403 || code == 404:
return "no_quota", truncateErr(oci.CompactError(err))
default:
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
}
}
return status, detail
}
// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先,
// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)。
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
var sorted []oci.GenAiModel
for _, m := range models {
if m.Capability == "" || m.Capability == "CHAT" {
sorted = append(sorted, m)
}
}
sort.SliceStable(sorted, func(i, j int) bool {
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
})
if len(sorted) > 3 {
sorted = sorted[:3]
}
return sorted
}
func probeScore(name string) int {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "voice") || strings.Contains(n, "embed") || strings.Contains(n, "rerank"):
return -10
case strings.Contains(n, "llama") && !strings.Contains(n, "vision"):
return 5
case strings.Contains(n, "gemini") || strings.Contains(n, "gpt-oss"):
return 4
case strings.Contains(n, "command"):
return 3
case strings.Contains(n, "grok") && !strings.Contains(n, "multi-agent"):
return 2
default:
return 0
}
}
// classifyProbeErr 区分「区域无服务端点」与其他错误。
func classifyProbeErr(err error) string {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "no such host") || strings.Contains(msg, "timeout") ||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "dial tcp") {
return "no_service"
}
return "error"
}
func truncateErr(msg string) string {
if len(msg) > 500 {
return msg[:500]
}
return msg
}
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return nil, fmt.Errorf("渠道不存在")
}
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
if err != nil {
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
}
if err := s.replaceModels(ctx, id, models); err != nil {
return nil, err
}
return s.channelModels(ctx, id)
}
// replaceModels 以事务整组覆盖渠道模型缓存。
func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, models []oci.GenAiModel) error {
rows := make([]model.AiModelCache, 0, len(models))
now := time.Now()
for _, m := range models {
rows = append(rows, model.AiModelCache{ChannelID: channelID, ModelOcid: m.Ocid, Name: m.Name, Vendor: m.Vendor,
Capability: m.Capability, SyncedAt: now, DeprecatedAt: m.Deprecated, RetiredAt: m.Retired})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", channelID).Delete(&model.AiModelCache{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
var rows []model.AiModelCache
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
return rows, err
}
// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models;
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
q := s.db.WithContext(ctx).
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
if group != "" {
q = q.Where("ai_channels.channel_group = ?", group)
}
var rows []model.AiModelCache
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
if err != nil {
return list, err
}
seen := map[string]bool{}
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
list.Data = append(list.Data, aiwire.Model{ID: r.Name, Object: "model", Created: r.SyncedAt.Unix(), OwnedBy: r.Vendor})
}
return list, nil
}
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
func (s *AiGatewayService) DeprecatingModels(ctx context.Context, within time.Duration) ([]string, error) {
now := time.Now()
deadline := now.Add(within)
var rows []model.AiModelCache
err := s.db.WithContext(ctx).
Where("(retired_at IS NOT NULL AND retired_at > ? AND retired_at <= ?) OR (deprecated_at IS NOT NULL AND deprecated_at >= ? AND deprecated_at <= ?)",
now, deadline, now, deadline).
Order("name ASC").Find(&rows).Error
if err != nil {
return nil, err
}
seen := map[string]bool{}
var out []string
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
if r.RetiredAt != nil && r.RetiredAt.After(now) && !r.RetiredAt.After(deadline) {
out = append(out, fmt.Sprintf("%s(%s 退役,届时无法调用)", r.Name, r.RetiredAt.Format("2006-01-02")))
continue
}
out = append(out, fmt.Sprintf("%s(%s 宣布弃用,退役前仍可调用)", r.Name, r.DeprecatedAt.Format("2006-01-02")))
}
return out, nil
}
// ProbeAll 逐个探测全部渠道,返回状态汇总;供 AI 探测后台任务调用。
func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
chs, err := s.Channels(ctx)
if err != nil {
return "", err
}
counts := map[string]int{}
var failures []string
for _, ch := range chs {
fresh, err := s.ProbeChannel(ctx, ch.ID)
if err != nil {
counts["error"]++
failures = append(failures, fmt.Sprintf("#%d %s", ch.ID, truncateErr(err.Error())))
continue
}
counts[fresh.ProbeStatus]++
}
msg := fmt.Sprintf("probed %d: %d ok, %d no_service, %d no_quota, %d error",
len(chs), counts["ok"], counts["no_service"], counts["no_quota"], counts["error"])
if len(failures) > 0 {
msg += "; " + strings.Join(failures, "; ")
}
return msg, nil
}
// ---- 调用日志 ----
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
entry.ErrMsg = truncateErr(entry.ErrMsg)
if err := s.db.Create(&entry).Error; err != nil {
log.Printf("ai call log: %v", err)
return 0
}
return entry.ID
}
// CallLogs 分页查询调用日志。
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 200 {
size = 50
}
var total int64
q := s.db.WithContext(ctx).Model(&model.AiCallLog{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AiCallLog
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err
}
// ---- 内容日志(红线例外,按密钥显式限时开启) ----
// UpdateKeyContentLog 设置密钥内容日志窗口:hours=0 立即关闭,>0 从现在起开启 N 小时(上限 7 天)。
func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hours int) (*model.AiKey, error) {
if hours < 0 || hours > aiContentLogMaxHours {
return nil, fmt.Errorf("内容日志时长需在 0-%d 小时之间", aiContentLogMaxHours)
}
updates := map[string]any{"content_log_until": gorm.Expr("NULL")}
if hours > 0 {
updates["content_log_until"] = time.Now().Add(time.Duration(hours) * time.Hour)
}
if err := s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.AiKey
if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil {
return nil, err
}
return &fresh, nil
}
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
entry.RequestBody = truncateBody(entry.RequestBody)
entry.ResponseBody = truncateBody(entry.ResponseBody)
if err := s.db.Create(&entry).Error; err != nil {
log.Printf("ai content log: %v", err)
}
}
func truncateBody(s string) string {
if len(s) > aiContentBodyLimit {
return s[:aiContentBodyLimit]
}
return s
}
// ContentLogs 分页查询内容日志(keyID / callLogID 为 0 时不过滤)。
func (s *AiGatewayService) ContentLogs(ctx context.Context, keyID, callLogID uint, page, size int) ([]model.AiContentLog, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := s.db.WithContext(ctx).Model(&model.AiContentLog{})
if keyID > 0 {
q = q.Where("key_id = ?", keyID)
}
if callLogID > 0 {
q = q.Where("call_log_id = ?", callLogID)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AiContentLog
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err
}
// StartCleanup 启动调用日志周期清理:启动即清一次,之后每 24h 一次。
func (s *AiGatewayService) StartCleanup(ctx context.Context) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.cleanupOnce(ctx)
ticker := time.NewTicker(aiLogCleanupTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupOnce(ctx)
}
}
}()
}
func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
s.cleanupTable(ctx, &model.AiCallLog{}, aiLogRetention, aiLogMaxRows, "ai log")
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
}
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
log.Printf("%s cleanup: %v", tag, err)
return
}
var total int64
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
return
}
if overflow := int(total) - maxRows; overflow > 0 {
var ids []uint
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
if len(ids) > 0 {
s.db.WithContext(ctx).Delete(m, ids)
}
}
}
// Wait 等待后台清理 goroutine 退出。
func (s *AiGatewayService) Wait() { s.wg.Wait() }
+283
View File
@@ -0,0 +1,283 @@
package service
import (
"context"
"errors"
"math/rand"
"time"
"gorm.io/gorm"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// ChatMeta 是一次网关调用的路由结果,供 API 层写调用日志。
type ChatMeta struct {
ChannelID uint
ChannelName string
Retries int
}
// aiCandidate 是某模型的一个可用渠道及其区域内模型 OCID。
type aiCandidate struct {
ch model.AiChannel
modelOcid string
}
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。
// group 非空时只在同分组渠道内路由(取自调用密钥)。
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
resp, err := s.callOnce(ctx, cand, ir)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
}
if !retryable(err) {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
}
func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, err
}
return s.client.GenAiChat(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
}
// OpenStream 编排流式调用:流建立成功后即绑定渠道,建立失败可换渠道重试。
// group 语义与 Chat 相同。
func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, meta, err
}
stream, err := s.client.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return stream, meta, nil
}
if !retryable(err) {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
}
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
func firstErr(lastErr, pickErr error) error {
if lastErr != nil && errors.Is(pickErr, ErrAiNoChannel) {
return lastErr
}
return pickErr
}
// retryable 判定是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。
func retryable(err error) bool {
if status, ok := oci.ServiceStatus(err); ok {
return status == 429 || status >= 500
}
return true
}
// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
func (s *AiGatewayService) pick(ctx context.Context, modelName, group, capability string, excluded map[uint]bool) (*aiCandidate, error) {
ocids, channelIDs, err := s.modelChannels(ctx, modelName, capability)
if err != nil {
return nil, err
}
if len(channelIDs) == 0 {
return nil, ErrAiUnknownModel
}
q := s.db.WithContext(ctx).Where("id IN ? AND enabled = ?", channelIDs, true)
if group != "" {
q = q.Where("channel_group = ?", group)
}
var channels []model.AiChannel
if err := q.Find(&channels).Error; err != nil {
return nil, err
}
now := time.Now()
avail := channels[:0]
for _, ch := range channels {
if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) {
continue
}
avail = append(avail, ch)
}
if len(avail) == 0 {
return nil, ErrAiNoChannel
}
chosen := weightedPick(topPriority(avail))
return &aiCandidate{ch: chosen, modelOcid: ocids[chosen.ID]}, nil
}
// modelChannels 查出提供该模型的渠道 ID 及各自的模型 OCID;
// capability=CHAT 时兼容存量空串(加列前只同步对话模型)。
func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capability string) (map[uint]string, []uint, error) {
q := s.db.WithContext(ctx).Where("name = ?", modelName)
if capability == "CHAT" {
q = q.Where("capability IN ?", []string{"CHAT", ""})
} else {
q = q.Where("capability = ?", capability)
}
var rows []model.AiModelCache
if err := q.Find(&rows).Error; err != nil {
return nil, nil, err
}
ocids := make(map[uint]string, len(rows))
ids := make([]uint, 0, len(rows))
for _, r := range rows {
ocids[r.ChannelID] = r.ModelOcid
ids = append(ids, r.ChannelID)
}
return ocids, ids, nil
}
// topPriority 保留最小 Priority 值的渠道组。
func topPriority(chs []model.AiChannel) []model.AiChannel {
best := chs[0].Priority
for _, ch := range chs[1:] {
if ch.Priority < best {
best = ch.Priority
}
}
out := chs[:0]
for _, ch := range chs {
if ch.Priority == best {
out = append(out, ch)
}
}
return out
}
// weightedPick 组内按权重随机;权重全部非正时等权。
func weightedPick(chs []model.AiChannel) model.AiChannel {
total := 0
for _, ch := range chs {
if ch.Weight > 0 {
total += ch.Weight
}
}
if total <= 0 {
return chs[rand.Intn(len(chs))]
}
r := rand.Intn(total)
for _, ch := range chs {
if ch.Weight <= 0 {
continue
}
r -= ch.Weight
if r < 0 {
return ch
}
}
return chs[len(chs)-1]
}
// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道(整请求上限 3 次)。
func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, req.Model, group, "EMBEDDING", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
resp, err := s.embedOnce(ctx, cand, req)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
}
if !retryable(err) {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
}
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
func (s *AiGatewayService) embedOnce(ctx context.Context, cand *aiCandidate, req aiwire.EmbeddingsRequest) (*aiwire.EmbeddingsResponse, error) {
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, err
}
vecs, usage, err := s.client.GenAiEmbed(ctx, cred, cand.ch.Region, cand.modelOcid, req.Input, req.Dimensions)
if err != nil {
return nil, err
}
out := &aiwire.EmbeddingsResponse{Object: "list", Model: req.Model, Data: make([]aiwire.Embedding, 0, len(vecs))}
for i, v := range vecs {
out.Data = append(out.Data, aiwire.Embedding{Object: "embedding", Index: i, Embedding: v})
}
if usage != nil {
out.Usage = &aiwire.EmbedUsage{PromptTokens: usage.PromptTokens, TotalTokens: usage.TotalTokens}
}
return out, nil
}
// markSuccess 复位失败计数与熔断窗口(无异常状态时零写库)。
func (s *AiGatewayService) markSuccess(ctx context.Context, id uint) {
s.db.WithContext(ctx).Model(&model.AiChannel{}).
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
}
// markFailure 递增失败计数;达到阈值后按 2^(超出次数) 分钟指数退避,封顶 30 分钟。
func (s *AiGatewayService) markFailure(ctx context.Context, id uint) {
s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).
Update("fail_count", gorm.Expr("fail_count + 1"))
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return
}
if ch.FailCount < aiFailThreshold {
return
}
n := ch.FailCount - aiFailThreshold
if n > 10 {
n = 10
}
backoff := time.Duration(1<<uint(n)) * time.Minute
if backoff > aiBackoffCap {
backoff = aiBackoffCap
}
until := time.Now().Add(backoff)
s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Update("disabled_until", until)
}
+508
View File
@@ -0,0 +1,508 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
type stubServiceError struct{ status int }
func (e stubServiceError) Error() string { return "stub service error" }
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
func (e stubServiceError) GetMessage() string { return "stub" }
func (e stubServiceError) GetCode() string { return "Stub" }
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }
// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
type gatewayStubClient struct {
*fakeClient
models []oci.GenAiModel
modelsErr error
probeCode int
probeErr error
chatResp *aiwire.ChatResponse
chatErrs []error
chatCalls int
regions []string
embedVecs [][]float32
embedUsage *aiwire.Usage
embedErr error
}
func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
return f.embedVecs, f.embedUsage, f.embedErr
}
func (f *gatewayStubClient) ListGenAiModels(ctx context.Context, cred oci.Credentials, region string) ([]oci.GenAiModel, error) {
return f.models, f.modelsErr
}
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
return f.probeCode, f.probeErr
}
func (f *gatewayStubClient) GenAiChat(ctx context.Context, cred oci.Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
f.chatCalls++
f.regions = append(f.regions, region)
if len(f.chatErrs) > 0 {
err := f.chatErrs[0]
f.chatErrs = f.chatErrs[1:]
if err != nil {
return nil, err
}
}
return f.chatResp, nil
}
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
t.Helper()
svc := newTestService(t, client)
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
t.Fatalf("auto migrate ai tables: %v", err)
}
return NewAiGatewayService(svc.db, svc, client), svc
}
func TestAiKeyLifecycle(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "")
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
}
if _, _, err := gw.CreateKey(ctx, "short", "abc", ""); err == nil {
t.Error("过短自定义密钥应被拒绝")
}
auto, _, err := gw.CreateKey(ctx, "auto", "", "")
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
}
got, err := gw.VerifyKey(ctx, "test-api-key")
if err != nil || got.Name != "免费-ai-api-key" {
t.Fatalf("VerifyKey = %+v, %v", got, err)
}
if _, err := gw.VerifyKey(ctx, "wrong"); !errors.Is(err, ErrAiKeyInvalid) {
t.Errorf("错误密钥 err = %v", err)
}
off := false
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil)
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
t.Errorf("禁用后 VerifyKey err = %v", err)
}
}
func TestAiChannelProbe(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
probeCode: 200,
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
if err != nil || ch.Name == "" || !ch.Enabled {
t.Fatalf("CreateChannel = %+v, %v", ch, err)
}
if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}); err == nil {
t.Error("重复渠道应被拒绝")
}
probed, err := gw.ProbeChannel(ctx, ch.ID)
if err != nil || probed.ProbeStatus != "ok" {
t.Fatalf("ProbeChannel = %+v, %v", probed, err)
}
models, err := gw.channelModels(ctx, ch.ID)
if err != nil || len(models) != 1 || models[0].Name != "meta.llama-3.3-70b-instruct" {
t.Fatalf("模型缓存未同步: %+v, %v", models, err)
}
// 配额拒绝 → no_quota
client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
probed, _ = gw.ProbeChannel(ctx, ch.ID)
if probed.ProbeStatus != "no_quota" {
t.Errorf("403 探测 status = %q, want no_quota", probed.ProbeStatus)
}
// 区域无模型 → no_service
client.models = nil
probed, _ = gw.ProbeChannel(ctx, ch.ID)
if probed.ProbeStatus != "no_service" {
t.Errorf("空模型探测 status = %q, want no_service", probed.ProbeStatus)
}
list, _ := gw.GatewayModels(ctx, "")
if len(list.Data) != 0 {
t.Errorf("no_service 后模型缓存应清空, got %+v", list.Data)
}
}
// seedChannel 直插渠道与模型缓存,绕过探测。
func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string, priority, weight int) *model.AiChannel {
t.Helper()
ch := &model.AiChannel{Name: region, OciConfigID: cfgID, Region: region, Enabled: true, Priority: priority, Weight: weight}
if err := gw.db.Create(ch).Error; err != nil {
t.Fatalf("seed channel: %v", err)
}
cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m-" + region, Name: "meta.llama-3.3-70b-instruct", Vendor: "meta", SyncedAt: time.Now()}
if err := gw.db.Create(cache).Error; err != nil {
t.Fatalf("seed cache: %v", err)
}
return ch
}
func TestAiChatRetrySwitchesChannel(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
chatErrs: []error{stubServiceError{status: 429}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
// 两个同优先级渠道
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
ctx := context.Background()
resp, meta, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "")
if err != nil || resp == nil {
t.Fatalf("Chat = %v, %v", resp, err)
}
if meta.Retries != 1 || client.chatCalls != 2 {
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls)
}
if len(client.regions) != 2 && client.regions[0] == client.regions[1] {
t.Errorf("重试未换渠道: %v", client.regions)
}
// 未知模型
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "no-such-model"}, ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("未知模型 err = %v", err)
}
}
func TestAiChatNonRetryablePassThrough(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatErrs: []error{stubServiceError{status: 400}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
_, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, "")
if err == nil || meta.Retries != 0 || client.chatCalls != 1 {
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.chatCalls)
}
}
func TestPickPriorityAndBreaker(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
high := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 2, 1)
ctx := context.Background()
cand, err := gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{})
if err != nil || cand.ch.ID != high.ID {
t.Fatalf("应选高优先级渠道: %+v, %v", cand, err)
}
// 高优先级熔断 → 降级到低优先级
until := time.Now().Add(10 * time.Minute)
gw.db.Model(&model.AiChannel{}).Where("id = ?", high.ID).Update("disabled_until", until)
cand, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{})
if err != nil || cand.ch.ID == high.ID {
t.Fatalf("熔断渠道应被跳过: %+v, %v", cand, err)
}
// 全部排除 → ErrAiNoChannel
_, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{cand.ch.ID: true})
if !errors.Is(err, ErrAiNoChannel) {
t.Errorf("全排除 err = %v", err)
}
}
func TestMarkFailureBackoff(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
ctx := context.Background()
for i := 0; i < aiFailThreshold; i++ {
gw.markFailure(ctx, ch.ID)
}
var got model.AiChannel
gw.db.First(&got, ch.ID)
if got.FailCount != aiFailThreshold || got.DisabledUntil == nil {
t.Fatalf("达到阈值应熔断: fail=%d until=%v", got.FailCount, got.DisabledUntil)
}
gw.markSuccess(ctx, ch.ID)
// 新变量重读:gorm 对 NULL 列不覆盖已有值的结构体字段
var reset model.AiChannel
gw.db.First(&reset, ch.ID)
if reset.FailCount != 0 || reset.DisabledUntil != nil {
t.Errorf("成功后应复位: %+v", reset)
}
}
func TestAiGroupRouting(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
vip := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
other := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
gw.db.Model(vip).Update("channel_group", "vip")
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
ctx := context.Background()
req := aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}
// 分组密钥只落同分组渠道
for i := 0; i < 5; i++ {
_, meta, err := gw.Chat(ctx, req, "vip")
if err != nil || meta.ChannelID != vip.ID {
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
}
}
// 分组内无渠道 → 无可用渠道
if _, _, err := gw.Chat(ctx, req, "nope"); !errors.Is(err, ErrAiNoChannel) {
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
}
// 模型列表按分组过滤
list, _ := gw.GatewayModels(ctx, "vip")
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
t.Errorf("vip 组模型 = %+v", list.Data)
}
all, _ := gw.GatewayModels(ctx, "")
if len(all.Data) != 2 {
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
}
// 密钥分组落库
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip")
if err != nil || key.Group != "vip" {
t.Fatalf("CreateKey group = %+v, %v", key, err)
}
empty := ""
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty)
var fresh model.AiKey
gw.db.First(&fresh, key.ID)
if fresh.Group != "" {
t.Errorf("清空分组后 = %q", fresh.Group)
}
}
func TestSyncAiProbeTask(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
probeCode: 200,
}
gw, svc := newTestGateway(t, client)
if err := gw.db.AutoMigrate(&model.Task{}, &model.TaskLog{}, &model.Setting{}); err != nil {
t.Fatalf("migrate task tables: %v", err)
}
tasks := NewTaskService(gw.db, svc, nil, nil)
tasks.AttachAiGateway(gw)
gw.SetOnChannelsChanged(tasks.SyncAiProbeTask)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 创建渠道 → 自动建任务并激活
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
var task model.Task
if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&task).Error; err != nil {
t.Fatalf("探测任务未自动创建: %v", err)
}
if task.Status != model.TaskStatusActive {
t.Errorf("任务状态 = %q, want active", task.Status)
}
if task.CronExpr != "0 0 * * *" {
t.Errorf("cron = %q, want 每天 00:00", task.CronExpr)
}
// 存量旧 cron 自动对齐
gw.db.Model(&task).Update("cron_expr", "*/10 * * * *")
tasks.SyncAiProbeTask(ctx)
var aligned model.Task
gw.db.First(&aligned, task.ID)
if aligned.CronExpr != "0 0 * * *" {
t.Errorf("存量 cron 未对齐: %q", aligned.CronExpr)
}
// 手动重复创建被拒
if _, err := tasks.CreateTask(ctx, CreateTaskInput{Name: "dup", Type: model.TaskTypeAiProbe, CronExpr: "*/10 * * * *"}); err == nil {
t.Error("重复 AI 探测任务应被拒绝")
}
// 立即执行一次:探测 ok
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil || entry == nil || !entry.Success {
t.Fatalf("RunTaskNow = %+v, %v", entry, err)
}
if !strings.Contains(entry.Message, "1 ok") {
t.Errorf("探测汇总 = %q", entry.Message)
}
// 手动删除被拒:任务由系统维护
if err := tasks.DeleteTask(ctx, task.ID); err == nil {
t.Error("手动删除 AI 探测任务应被拒绝")
}
// 渠道归零 → 任务连同日志自动删除
if err := gw.DeleteChannel(ctx, ch.ID); err != nil {
t.Fatalf("DeleteChannel: %v", err)
}
var gone int64
gw.db.Model(&model.Task{}).Where("type = ?", model.TaskTypeAiProbe).Count(&gone)
if gone != 0 {
t.Errorf("渠道归零后任务应被删除,剩 %d", gone)
}
var logsLeft int64
gw.db.Model(&model.TaskLog{}).Where("task_id = ?", task.ID).Count(&logsLeft)
if logsLeft != 0 {
t.Errorf("任务日志应随任务删除,剩 %d", logsLeft)
}
// 再建渠道 → 自动新建任务
if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "us-chicago-1"}); err != nil {
t.Fatalf("CreateChannel again: %v", err)
}
var again model.Task
if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&again).Error; err != nil {
t.Fatalf("渠道恢复后任务未重建: %v", err)
}
if again.Status != model.TaskStatusActive {
t.Errorf("渠道恢复后任务状态 = %q, want active", again.Status)
}
}
func TestProbeCandidates(t *testing.T) {
models := []oci.GenAiModel{
{Ocid: "o1", Name: "xai.grok-voice-agent"},
{Ocid: "o2", Name: "cohere.command-r-plus"},
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
}
got := probeCandidates(models)
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
t.Errorf("候选排序 = %+v", got)
}
for _, m := range got {
if m.Name == "xai.grok-voice-agent" {
t.Error("voice 模型不应进候选前 3")
}
}
}
func TestDeprecatingModels(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
now := time.Now()
soon := now.Add(10 * 24 * time.Hour)
far := now.Add(90 * 24 * time.Hour)
past := now.Add(-30 * 24 * time.Hour)
retireSoon := now.Add(15 * 24 * time.Hour)
rows := []model.AiModelCache{
{ChannelID: 1, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon},
{ChannelID: 2, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon}, // 跨渠道去重
{ChannelID: 1, Name: "xai.grok-4.3", SyncedAt: now, DeprecatedAt: &far}, // 弃用窗口外
{ChannelID: 1, Name: "cohere.command-latest", SyncedAt: now}, // 未宣布
{ChannelID: 1, Name: "xai.grok-3", SyncedAt: now, DeprecatedAt: &past}, // 已过弃用日仍可用:不再告警
{ChannelID: 1, Name: "meta.llama-3.2-11b", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &retireSoon}, // 即将退役:重点告警
{ChannelID: 1, Name: "cohere.embed-img", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &far}, // 退役窗口外
}
if err := gw.db.Create(&rows).Error; err != nil {
t.Fatalf("seed: %v", err)
}
names, err := gw.DeprecatingModels(ctx, 30*24*time.Hour)
if err != nil || len(names) != 2 {
t.Fatalf("DeprecatingModels = %v, %v, want 2 条", names, err)
}
joined := strings.Join(names, "\n")
if !strings.Contains(joined, "meta.llama-3.2-11b(") || !strings.Contains(joined, "退役,届时无法调用") {
t.Errorf("缺少退役告警: %v", names)
}
if !strings.Contains(joined, "meta.llama-old(") || !strings.Contains(joined, "退役前仍可调用") {
t.Errorf("缺少弃用预告: %v", names)
}
if strings.Contains(joined, "xai.grok-3(") {
t.Errorf("已过弃用日且未近退役的模型不应告警: %v", names)
}
}
func TestAiEmbeddings(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
embedVecs: [][]float32{{0.1, 0.2}, {0.3, 0.4}},
embedUsage: &aiwire.Usage{PromptTokens: 6, TotalTokens: 6},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..emb", Name: "cohere.embed-v4.0", Vendor: "cohere", Capability: "EMBEDDING", SyncedAt: time.Now()})
ctx := context.Background()
resp, meta, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "cohere.embed-v4.0", Input: aiwire.StringList{"a", "b"}}, "")
if err != nil || len(resp.Data) != 2 || resp.Data[1].Index != 1 || resp.Usage.TotalTokens != 6 {
t.Fatalf("Embeddings = %+v, meta=%+v, %v", resp, meta, err)
}
if resp.Object != "list" || resp.Data[0].Object != "embedding" {
t.Errorf("响应形态 = %+v", resp)
}
// 对话模型名打 embeddings:能力不匹配 → 未知模型
if _, _, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "meta.llama-3.3-70b-instruct", Input: aiwire.StringList{"a"}}, ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
}
// embedding 模型名打 chat:同样未知模型
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "cohere.embed-v4.0", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("x")}}}, ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
}
}
func TestAiContentLogSwitch(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
t.Fatalf("migrate content log: %v", err)
}
ctx := context.Background()
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "")
if err != nil {
t.Fatalf("CreateKey: %v", err)
}
if key.ContentLogUntil != nil {
t.Error("新密钥内容日志应默认永关")
}
// 开启 24 小时
fresh, err := gw.UpdateKeyContentLog(ctx, key.ID, 24)
if err != nil || fresh.ContentLogUntil == nil || time.Until(*fresh.ContentLogUntil) < 23*time.Hour {
t.Fatalf("开启失败: %+v, %v", fresh.ContentLogUntil, err)
}
// 超上限拒绝
if _, err := gw.UpdateKeyContentLog(ctx, key.ID, 169); err == nil {
t.Error("超过 7 天上限应被拒绝")
}
// 写入与截断(带调用日志关联)
gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)})
rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20)
if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 {
t.Fatalf("ContentLogs = total %d len %d, %v", total, len(rows[0].RequestBody), err)
}
// 按调用日志 ID 反查:命中与不命中
if rows, total, err = gw.ContentLogs(ctx, 0, 42, 1, 20); err != nil || total != 1 || rows[0].CallLogID != 42 {
t.Fatalf("按 callLogId 反查 = total %d, %v", total, err)
}
if _, total, err = gw.ContentLogs(ctx, 0, 999, 1, 20); err != nil || total != 0 {
t.Fatalf("callLogId 不命中应为空 = total %d, %v", total, err)
}
// 关闭
fresh, err = gw.UpdateKeyContentLog(ctx, key.ID, 0)
if err != nil || fresh.ContentLogUntil != nil {
t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err)
}
}
+243
View File
@@ -0,0 +1,243 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。
// 有状态特性与内置工具不支持,直接报错(API 层 400)。
func ResponsesToIR(req aiwire.RespRequest) (aiwire.ChatRequest, error) {
if err := respRejectUnsupported(req); err != nil {
return aiwire.ChatRequest{}, err
}
ir := aiwire.ChatRequest{
Model: req.Model,
MaxTokens: req.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
ToolChoice: respToolChoice(req.ToolChoice),
}
if req.Reasoning != nil {
ir.ReasoningEffort = req.Reasoning.Effort
}
if req.Instructions != "" {
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(req.Instructions)})
}
msgs, err := respInputToMessages(req.Input)
if err != nil {
return aiwire.ChatRequest{}, err
}
ir.Messages = append(ir.Messages, msgs...)
ir.Tools = respTools(req.Tools)
ir.ResponseFormat = respFormat(req.Text)
return ir, nil
}
// respRejectUnsupported 拒绝无状态网关无法承接的请求特性。
func respRejectUnsupported(req aiwire.RespRequest) error {
if req.PreviousResponseID != "" {
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
}
if len(req.Conversation) > 0 && string(req.Conversation) != "null" {
return fmt.Errorf("conversation 不支持:网关不保存对话状态")
}
if req.Background != nil && *req.Background {
return fmt.Errorf("background 模式不支持")
}
for _, t := range req.Tools {
if t.Type != "function" {
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
}
}
return nil
}
// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。
func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) {
if !in.IsArray {
if strings.TrimSpace(in.Text) == "" {
return nil, fmt.Errorf("input 不能为空")
}
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil
}
var msgs []aiwire.ChatMessage
for _, it := range in.Items {
out, err := respItemToMessages(it, msgs)
if err != nil {
return nil, err
}
msgs = out
}
return msgs, nil
}
// respItemToMessages 追加一个 input item;连续 function_call 合并进同一 assistant 消息。
func respItemToMessages(it aiwire.RespItem, msgs []aiwire.ChatMessage) ([]aiwire.ChatMessage, error) {
switch it.Type {
case "", "message":
if it.Content.HasUnsupported() {
return nil, ErrAiUnsupportedBlock
}
role := respRole(it.Role)
return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil
case "function_call":
call := aiwire.ToolCall{ID: it.CallID, Type: "function",
Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}}
if n := len(msgs); n > 0 && msgs[n-1].Role == "assistant" && len(msgs[n-1].ToolCalls) > 0 {
msgs[n-1].ToolCalls = append(msgs[n-1].ToolCalls, call)
return msgs, nil
}
return append(msgs, aiwire.ChatMessage{Role: "assistant", ToolCalls: []aiwire.ToolCall{call}}), nil
case "function_call_output":
return append(msgs, aiwire.ChatMessage{Role: "tool", ToolCallID: it.CallID,
Content: aiwire.NewTextContent(it.OutputText())}), nil
case "reasoning":
return msgs, nil // 推理块不回灌上游
default:
return nil, fmt.Errorf("不支持的 input item 类型 %q", it.Type)
}
}
// respRole 归一 item 角色;developer 视为 system,缺省 user。
func respRole(role string) string {
switch role {
case "system", "developer":
return "system"
case "assistant":
return "assistant"
default:
return "user"
}
}
// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。
func respContentToIR(c aiwire.RespContent) aiwire.Content {
var parts []aiwire.ContentPart
hasImage := false
for _, p := range c.Parts {
switch p.Type {
case "input_image":
parts = append(parts, aiwire.ContentPart{Type: "image_url",
ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}})
hasImage = true
default:
if p.Text != "" {
parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text})
}
}
}
if !c.IsArray || !hasImage {
return aiwire.NewTextContent(c.JoinText())
}
return aiwire.NewPartsContent(parts)
}
// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。
func respTools(tools []aiwire.RespTool) []aiwire.Tool {
if len(tools) == 0 {
return nil
}
out := make([]aiwire.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, aiwire.Tool{Type: "function",
Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}})
}
return out
}
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
// allowed_tools 等对象形态降级 auto。
func respToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
t := strings.TrimSpace(string(raw))
if strings.HasPrefix(t, "\"") {
return raw
}
var obj struct {
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" {
return json.RawMessage(`"auto"`)
}
out, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": obj.Name}})
return out
}
// respFormat 把 text.format 转成 response_format;text 形态无需显式指定。
func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat {
if text == nil || text.Format == nil {
return nil
}
switch text.Format.Type {
case "json_object":
return &aiwire.ResponseFormat{Type: "json_object"}
case "json_schema":
spec, _ := json.Marshal(map[string]any{
"name": text.Format.Name, "schema": json.RawMessage(text.Format.Schema), "strict": text.Format.Strict,
})
return &aiwire.ResponseFormat{Type: "json_schema", JSONSchema: spec}
default:
return nil
}
}
// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。
func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response {
out := aiwire.Response{ID: id, Object: "response", CreatedAt: created,
Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false}
if len(resp.Choices) == 0 {
return out
}
choice := resp.Choices[0]
if text := choice.Message.Content.JoinText(); text != "" {
out.Output = append(out.Output, respMessageItem(id, 0, text))
}
for i, tc := range choice.Message.ToolCalls {
out.Output = append(out.Output, aiwire.RespOutItem{Type: "function_call",
ID: respItemID(id, "fc", len(out.Output)+i), Status: "completed",
CallID: tc.ID, Name: tc.Function.Name, Arguments: respArgs(tc.Function.Arguments)})
}
if choice.FinishReason == "length" {
out.Status = "incomplete"
out.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
}
out.Usage = respUsage(resp.Usage)
return out
}
func respMessageItem(respID string, idx int, text string) aiwire.RespOutItem {
return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx),
Status: "completed", Role: "assistant",
Content: []aiwire.RespOutPart{{Type: "output_text", Text: text, Annotations: []any{}}}}
}
// respItemID 从响应 ID 派生确定性 item ID。
func respItemID(respID, kind string, idx int) string {
return fmt.Sprintf("%s_%s_%d", kind, strings.TrimPrefix(respID, "resp_"), idx)
}
// respArgs 保证 arguments 是合法 JSON 字符串(空实参回退 {})。
func respArgs(args string) string {
if strings.TrimSpace(args) == "" {
return "{}"
}
return args
}
// respUsage 把 IR 用量改写为 Responses 命名口径。
func respUsage(u *aiwire.Usage) *aiwire.RespUsage {
if u == nil {
return nil
}
return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}}
}
+199
View File
@@ -0,0 +1,199 @@
package service
import (
"strings"
"oci-portal/internal/aiwire"
)
// RespEvent 是一条 Responses SSE 语义事件(event 名 + data 载荷)。
type RespEvent struct {
Event string
Data map[string]any
}
// RespStream 把 IR chunk 流聚合为 Responses 语义事件序列。
// 与 AnthStream 同构,但终态事件须携带完整 Response 快照,故全程缓冲文本与实参。
type RespStream struct {
id string
model string
created int64
seq int
started bool
items []aiwire.RespOutItem
msgOpen bool
text strings.Builder
tool aiwire.RespOutItem
toolIdx int
tOpen bool
args strings.Builder
finish string
usage *aiwire.Usage
}
// NewRespStream 构造状态机;id 形如 resp_*,created 为响应时间戳。
func NewRespStream(id, model string, created int64) *RespStream {
return &RespStream{id: id, model: model, created: created}
}
// Feed 消化一个上游 chunk,返回应立即下发的事件。
func (s *RespStream) Feed(chunk aiwire.ChatChunk) []RespEvent {
var evs []RespEvent
if !s.started {
s.started = true
evs = append(evs, s.respEvent("response.created", "in_progress"),
s.respEvent("response.in_progress", "in_progress"))
}
if chunk.Usage != nil {
s.usage = chunk.Usage
}
if len(chunk.Choices) == 0 {
return evs
}
choice := chunk.Choices[0]
if choice.Delta.Content != "" {
evs = append(evs, s.feedText(choice.Delta.Content)...)
}
for _, tc := range choice.Delta.ToolCalls {
evs = append(evs, s.feedTool(tc)...)
}
if choice.FinishReason != nil {
s.finish = *choice.FinishReason
}
return evs
}
// Finish 关闭未闭合的块并产出终态事件(带完整 Response 快照)。
func (s *RespStream) Finish() []RespEvent {
var evs []RespEvent
if !s.started {
s.started = true
evs = append(evs, s.respEvent("response.created", "in_progress"))
}
evs = append(evs, s.closeMsg()...)
evs = append(evs, s.closeTool()...)
status := "completed"
if s.finish == "length" {
status = "incomplete"
}
return append(evs, s.respEvent("response."+status, status))
}
// Usage 返回聚合到的用量(可能为 nil),供调用日志。
func (s *RespStream) Usage() *aiwire.Usage { return s.usage }
// ev 构造带自增 sequence_number 的事件。
func (s *RespStream) ev(typ string, kv map[string]any) RespEvent {
s.seq++
data := map[string]any{"type": typ, "sequence_number": s.seq}
for k, v := range kv {
data[k] = v
}
return RespEvent{Event: typ, Data: data}
}
// respEvent 构造携带 Response 快照的生命周期事件。
func (s *RespStream) respEvent(typ, status string) RespEvent {
return s.ev(typ, map[string]any{"response": s.snapshot(status)})
}
// snapshot 组装当前累计状态的 Response 对象。
func (s *RespStream) snapshot(status string) aiwire.Response {
resp := aiwire.Response{ID: s.id, Object: "response", CreatedAt: s.created,
Status: status, Model: s.model, Store: false,
Output: append([]aiwire.RespOutItem{}, s.items...)}
resp.Usage = respUsage(s.usage)
if status == "incomplete" {
resp.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
}
return resp
}
// feedText 处理文本增量:必要时开 message item 与 content part。
func (s *RespStream) feedText(delta string) []RespEvent {
evs := s.closeTool()
if !s.msgOpen {
s.msgOpen = true
s.text.Reset()
item := aiwire.RespOutItem{Type: "message", ID: s.itemID("msg"), Status: "in_progress",
Role: "assistant", Content: []aiwire.RespOutPart{}}
evs = append(evs, s.ev("response.output_item.added", map[string]any{
"output_index": len(s.items), "item": item}))
evs = append(evs, s.ev("response.content_part.added", map[string]any{
"item_id": item.ID, "output_index": len(s.items), "content_index": 0,
"part": aiwire.RespOutPart{Type: "output_text", Text: "", Annotations: []any{}}}))
}
s.text.WriteString(delta)
return append(evs, s.ev("response.output_text.delta", map[string]any{
"item_id": s.itemID("msg"), "output_index": len(s.items), "content_index": 0, "delta": delta}))
}
// closeMsg 闭合当前 message item(text done → part done → item done)。
func (s *RespStream) closeMsg() []RespEvent {
if !s.msgOpen {
return nil
}
s.msgOpen = false
id, idx, full := s.itemID("msg"), len(s.items), s.text.String()
part := aiwire.RespOutPart{Type: "output_text", Text: full, Annotations: []any{}}
item := aiwire.RespOutItem{Type: "message", ID: id, Status: "completed",
Role: "assistant", Content: []aiwire.RespOutPart{part}}
evs := []RespEvent{
s.ev("response.output_text.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "text": full}),
s.ev("response.content_part.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "part": part}),
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": item}),
}
s.items = append(s.items, item)
return evs
}
// feedTool 处理工具调用增量:新 Index 先闭合旧调用再开新 item。
func (s *RespStream) feedTool(tc aiwire.ToolCallDelta) []RespEvent {
evs := s.closeMsg()
if s.tOpen && tc.Index != s.toolIdx {
evs = append(evs, s.closeTool()...)
}
if !s.tOpen {
s.tOpen, s.toolIdx = true, tc.Index
s.args.Reset()
s.tool = aiwire.RespOutItem{Type: "function_call", ID: s.itemID("fc"), Status: "in_progress",
CallID: tc.ID, Name: tc.Function.Name}
evs = append(evs, s.ev("response.output_item.added", map[string]any{
"output_index": len(s.items), "item": s.tool}))
}
if tc.ID != "" && s.tool.CallID == "" {
s.tool.CallID = tc.ID
}
if tc.Function.Name != "" && s.tool.Name == "" {
s.tool.Name = tc.Function.Name
}
if tc.Function.Arguments == "" {
return evs
}
s.args.WriteString(tc.Function.Arguments)
return append(evs, s.ev("response.function_call_arguments.delta", map[string]any{
"item_id": s.tool.ID, "output_index": len(s.items), "delta": tc.Function.Arguments}))
}
// closeTool 闭合当前 function_call item(arguments done → item done)。
func (s *RespStream) closeTool() []RespEvent {
if !s.tOpen {
return nil
}
s.tOpen = false
s.tool.Arguments = respArgs(s.args.String())
s.tool.Status = "completed"
idx := len(s.items)
evs := []RespEvent{
s.ev("response.function_call_arguments.done", map[string]any{
"item_id": s.tool.ID, "output_index": idx, "arguments": s.tool.Arguments}),
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": s.tool}),
}
s.items = append(s.items, s.tool)
return evs
}
// itemID 按当前 output 序号派生确定性 item ID。
func (s *RespStream) itemID(kind string) string {
return respItemID(s.id, kind, len(s.items))
}
+176
View File
@@ -0,0 +1,176 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func respReq(t *testing.T, raw string) aiwire.RespRequest {
t.Helper()
var req aiwire.RespRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("unmarshal req: %v", err)
}
return req
}
func TestResponsesToIR(t *testing.T) {
req := respReq(t, `{
"model": "meta.llama-3.3-70b-instruct",
"instructions": "你是助手",
"max_output_tokens": 128,
"input": [
{"role": "user", "content": "查天气"},
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"},
{"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]}
],
"tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}],
"tool_choice": {"type": "function", "name": "get_weather"},
"text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}}
}`)
ir, err := ResponsesToIR(req)
if err != nil {
t.Fatalf("ResponsesToIR: %v", err)
}
roles := make([]string, 0, len(ir.Messages))
for _, m := range ir.Messages {
roles = append(roles, m.Role)
}
want := []string{"system", "user", "assistant", "tool", "assistant"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Errorf("roles = %v, want %v", roles, want)
}
if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" {
t.Errorf("tool call 链接错误: %+v", ir.Messages)
}
if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" {
t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools)
}
if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") {
t.Errorf("tool_choice = %s", ir.ToolChoice)
}
if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" {
t.Errorf("response_format = %+v", ir.ResponseFormat)
}
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
func TestResponsesToIRRejects(t *testing.T) {
cases := []struct{ name, raw string }{
{"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`},
{"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`},
{"background", `{"model":"m","input":"hi","background":true}`},
{"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`},
{"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`},
{"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`},
}
for _, c := range cases {
if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil {
t.Errorf("%s 应被拒绝", c.name)
}
}
// input 字符串形态 + store/reasoning 忽略项不报错
req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`)
ir, err := ResponsesToIR(req)
if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" {
t.Errorf("字符串 input = %+v, %v", ir.Messages, err)
}
// input_image(url 形态)放行并保留图文顺序
req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`)
ir2, err := ResponsesToIR(req2)
if err != nil {
t.Fatalf("input_image 应放行: %v", err)
}
parts := ir2.Messages[0].Content.Parts
if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" {
t.Errorf("parts = %+v", parts)
}
}
func TestIRRespToResponses(t *testing.T) {
resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{
Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"),
ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function",
Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}},
FinishReason: "length",
}}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}
out := IRRespToResponses(resp, "resp_abc", 1751966400)
if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
t.Errorf("length 应映射 incomplete: %+v", out)
}
if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" {
t.Fatalf("output = %+v", out.Output)
}
if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" {
t.Errorf("item 装配错误: %+v", out.Output)
}
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store {
t.Errorf("usage/store 错误: %+v", out)
}
}
func chunkText(text string) aiwire.ChatChunk {
return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}}
}
func TestRespStreamTextAndTool(t *testing.T) {
st := NewRespStream("resp_x", "m", 1751966400)
var types []string
collect := func(evs []RespEvent) {
for _, ev := range evs {
types = append(types, ev.Event)
}
}
collect(st.Feed(chunkText("你")))
collect(st.Feed(chunkText("好")))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}}))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}},
Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}}))
finish := st.Finish()
collect(finish)
want := []string{
"response.created", "response.in_progress",
"response.output_item.added", "response.content_part.added", "response.output_text.delta",
"response.output_text.delta",
"response.output_text.done", "response.content_part.done", "response.output_item.done",
"response.output_item.added", "response.function_call_arguments.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done", "response.output_item.done",
"response.completed",
}
if strings.Join(types, "\n") != strings.Join(want, "\n") {
t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n"))
}
last := finish[len(finish)-1].Data
resp := last["response"].(aiwire.Response)
if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" {
t.Errorf("终态快照 = %+v", resp.Output)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 10 {
t.Errorf("终态 usage = %+v", resp.Usage)
}
// sequence_number 严格递增
if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) {
t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want))
}
}
func TestRespStreamEmpty(t *testing.T) {
st := NewRespStream("resp_e", "m", 1)
evs := st.Finish()
if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" {
t.Errorf("空流事件 = %+v", evs)
}
}
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"context"
"fmt"
"oci-portal/internal/oci"
)
// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.ChangeInstancePublicIP(ctx, cred, region, instanceID)
}
// AddInstanceIpv6 为实例主 VNIC 分配 IPv6 地址;address 为空时自动分配。
func (s *OciConfigService) AddInstanceIpv6(ctx context.Context, id uint, region, instanceID, address string) (string, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.AddInstanceIpv6(ctx, cred, region, instanceID, address)
}
// DeleteInstanceIpv6 取消分配实例上的指定 IPv6 地址。
func (s *OciConfigService) DeleteInstanceIpv6(ctx context.Context, id uint, region, instanceID, address string) error {
if address == "" {
return fmt.Errorf("delete ipv6: address is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteInstanceIpv6(ctx, cred, region, instanceID, address)
}
// InstanceVnics 列出实例的全部 VNIC。
func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, instanceID string) ([]oci.Vnic, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListInstanceVnics(ctx, cred, region, instanceID)
}
// AttachVnic 为实例附加次要 VNIC。
func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
if instanceID == "" || in.SubnetID == "" {
return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Vnic{}, err
}
return s.client.AttachVnic(ctx, cred, region, instanceID, in)
}
// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。
func (s *OciConfigService) DetachVnic(ctx context.Context, id uint, region, attachmentID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DetachVnic(ctx, cred, region, attachmentID)
}
// AddVnicIpv6 为指定 VNIC 分配 IPv6;address 留空自动分配。
func (s *OciConfigService) AddVnicIpv6(ctx context.Context, id uint, region, vnicID, address string) (string, error) {
if vnicID == "" {
return "", fmt.Errorf("add vnic ipv6: vnicId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.AddVnicIpv6(ctx, cred, region, vnicID, address)
}
// BootVolumeAttachments 列出实例的引导卷挂载。
func (s *OciConfigService) BootVolumeAttachments(ctx context.Context, id uint, region, instanceID string) ([]oci.BootVolumeAttachment, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListBootVolumeAttachments(ctx, cred, region, instanceID)
}
// AttachBootVolume 为实例挂载引导卷(实例需 STOPPED)。
func (s *OciConfigService) AttachBootVolume(ctx context.Context, id uint, region, instanceID, bootVolumeID string) (oci.BootVolumeAttachment, error) {
if instanceID == "" || bootVolumeID == "" {
return oci.BootVolumeAttachment{}, fmt.Errorf("attach boot volume: instanceId and bootVolumeId are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.BootVolumeAttachment{}, err
}
return s.client.AttachBootVolume(ctx, cred, region, instanceID, bootVolumeID)
}
// DetachBootVolume 分离引导卷挂载(实例需 STOPPED)。
func (s *OciConfigService) DetachBootVolume(ctx context.Context, id uint, region, attachmentID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DetachBootVolume(ctx, cred, region, attachmentID)
}
// ReplaceBootVolume 替换实例引导卷(实例需 STOPPED)。
func (s *OciConfigService) ReplaceBootVolume(ctx context.Context, id uint, region, instanceID, bootVolumeID string) (oci.BootVolumeAttachment, error) {
if instanceID == "" || bootVolumeID == "" {
return oci.BootVolumeAttachment{}, fmt.Errorf("replace boot volume: instanceId and bootVolumeId are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.BootVolumeAttachment{}, err
}
return s.client.ReplaceBootVolume(ctx, cred, region, instanceID, bootVolumeID)
}
// BlockVolumes 列出块存储卷;compartmentID 为空表示租户根。
func (s *OciConfigService) BlockVolumes(ctx context.Context, id uint, region, availabilityDomain, compartmentID string) ([]oci.BlockVolume, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListBlockVolumes(ctx, cred, region, availabilityDomain)
}
// VolumeAttachments 列出实例的块卷挂载。
func (s *OciConfigService) VolumeAttachments(ctx context.Context, id uint, region, instanceID string) ([]oci.VolumeAttachment, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListVolumeAttachments(ctx, cred, region, instanceID)
}
// AttachVolume 为实例附加块卷。
func (s *OciConfigService) AttachVolume(ctx context.Context, id uint, region, instanceID, volumeID string, readOnly bool) (oci.VolumeAttachment, error) {
if instanceID == "" || volumeID == "" {
return oci.VolumeAttachment{}, fmt.Errorf("attach volume: instanceId and volumeId are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.VolumeAttachment{}, err
}
return s.client.AttachVolume(ctx, cred, region, instanceID, volumeID, readOnly)
}
// DetachVolume 分离块卷挂载。
func (s *OciConfigService) DetachVolume(ctx context.Context, id uint, region, attachmentID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DetachVolume(ctx, cred, region, attachmentID)
}
+136
View File
@@ -0,0 +1,136 @@
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
}
+186
View File
@@ -0,0 +1,186 @@
package service
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"oci-portal/internal/oci"
)
// auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。
type auditStubClient struct {
*fakeClient
result oci.AuditEventsResult
gotRegion string
gotStart time.Time
gotEnd time.Time
gotPage string
calls int
}
func (f *auditStubClient) ListAuditEvents(ctx context.Context, cred oci.Credentials, region string, start, end time.Time, page string) (oci.AuditEventsResult, error) {
f.calls++
f.gotRegion, f.gotStart, f.gotEnd, f.gotPage = region, start, end, page
return f.result, nil
}
func TestAuditEventsHoursValidation(t *testing.T) {
tests := []struct {
name string
hours int
wantErr bool
}{
{name: "下界 1 小时", hours: 1},
{name: "默认 24 小时", hours: 24},
{name: "上界 72 小时", hours: 72},
{name: "0 越界", hours: 0, wantErr: true},
{name: "负数越界", hours: -3, wantErr: true},
{name: "100 越界", hours: 100, wantErr: true},
}
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{{EventName: "GetInstance"}}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Hours: tt.hours})
if tt.wantErr {
if !errors.Is(err, ErrInvalidAuditHours) {
t.Fatalf("AuditEvents(hours=%d) error = %v, want ErrInvalidAuditHours", tt.hours, err)
}
return
}
if err != nil {
t.Fatalf("AuditEvents(hours=%d): %v", tt.hours, err)
}
if len(got.Items) != 1 {
t.Errorf("items = %d, want 1", len(got.Items))
}
})
}
}
func TestAuditEventsWindowAndRegion(t *testing.T) {
client := &auditStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Region: "ap-tokyo-1", Hours: 6})
if err != nil {
t.Fatalf("AuditEvents: %v", err)
}
if client.calls != 1 {
t.Fatalf("calls = %d, want 1", client.calls)
}
if client.gotRegion != "ap-tokyo-1" {
t.Errorf("region = %q, want %q", client.gotRegion, "ap-tokyo-1")
}
if window := client.gotEnd.Sub(client.gotStart); window != 6*time.Hour {
t.Errorf("window = %v, want %v", window, 6*time.Hour)
}
// 响应回传分钟粒度的绝对窗,续查据此原样带回
if got.Start.Second() != 0 || !got.End.After(got.Start) {
t.Errorf("响应窗口 = [%v, %v), 应为分钟粒度且有序", got.Start, got.End)
}
}
func TestAuditEventsResume(t *testing.T) {
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{}, Truncated: true, NextPage: "tok-2"},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 续查:绝对窗 + 游标透传;NextPage 原样回传
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{
Start: "2026-07-08T10:00:00Z", End: "2026-07-09T10:00:00Z", Page: "tok-1",
})
if err != nil {
t.Fatalf("续查: %v", err)
}
if client.gotPage != "tok-1" || !got.Truncated || got.NextPage != "tok-2" {
t.Errorf("游标透传 page=%q next=%q truncated=%v", client.gotPage, got.NextPage, got.Truncated)
}
if !client.gotStart.Equal(time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)) {
t.Errorf("续查未用绝对窗: start=%v", client.gotStart)
}
// 非法窗口逐项拒绝
bad := []AuditQuery{
{Start: "not-a-time", End: "2026-07-09T10:00:00Z"},
{Start: "2026-07-09T10:00:00Z", End: "2026-07-08T10:00:00Z"}, // 倒序
{Start: "2026-07-01T00:00:00Z", End: "2026-07-09T10:00:00Z"}, // 超 72h
{Page: "tok-only"}, // 带游标缺窗口
}
for i, q := range bad {
if _, err := svc.AuditEvents(ctx, cfg.ID, q); !errors.Is(err, ErrInvalidAuditWindow) {
t.Errorf("bad[%d] err = %v, want ErrInvalidAuditWindow", i, err)
}
}
}
func TestAuditRawStrippedAndDetail(t *testing.T) {
raw := json.RawMessage(`{"eventId":"evt-1","full":true}`)
eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC)
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{
{EventId: "evt-1", EventTime: &eventTime, EventName: "TerminateInstance", Raw: raw},
}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Hours: 1})
if err != nil {
t.Fatalf("AuditEvents: %v", err)
}
if got.Items[0].Raw != nil {
t.Error("列表响应应剥离 raw")
}
// 详情走缓存,不再回源
detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-1", eventTime)
if err != nil || string(detail) != string(raw) {
t.Fatalf("AuditEventDetail = %s, %v", detail, err)
}
if client.calls != 1 {
t.Errorf("缓存命中不应回源, calls = %d", client.calls)
}
}
func TestAuditDetailRequeryFallback(t *testing.T) {
raw := json.RawMessage(`{"eventId":"evt-2"}`)
eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC)
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{
{EventId: "evt-2", EventTime: &eventTime, Raw: raw},
}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 缓存未预热:miss 走事件时间小窗重查找回
detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-2", eventTime)
if err != nil || string(detail) != string(raw) {
t.Fatalf("兜底重查 = %s, %v", detail, err)
}
if client.calls != 1 {
t.Fatalf("应回源一次, calls = %d", client.calls)
}
if win := client.gotEnd.Sub(client.gotStart); win != 2*time.Minute || client.gotStart.Second() != 0 {
t.Errorf("兜底窗口 = [%v, +%v), 应为事件分钟起 2 分钟", client.gotStart, win)
}
// 重查也找不到 → ErrAuditEventGone
if _, err := svc.AuditEventDetail(ctx, cfg.ID, "", "no-such", eventTime); !errors.Is(err, ErrAuditEventGone) {
t.Errorf("未找回 err = %v, want ErrAuditEventGone", err)
}
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"oci-portal/internal/cache"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// ErrInvalidCredentials 表示用户名或密码错误;不区分两者以免泄露账号是否存在。
var ErrInvalidCredentials = errors.New("invalid username or password")
// ErrLoginLocked 表示该 IP+用户名组合因连续失败被锁定;不提示剩余次数与时长细节。
var ErrLoginLocked = errors.New("too many failed attempts, try again later")
// tokenTTL 是登录令牌有效期。
const tokenTTL = 24 * time.Hour
// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"),
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)和 JWT 签发与验证。
type AuthService struct {
db *gorm.DB
jwtSecret []byte
guard *loginGuard
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
revoked *cache.Cache
notifier *Notifier
settings *SettingService
cipher *crypto.Cipher // TOTP 密钥加密落库用,SetCipher 注入
totpMu sync.Mutex
totpPending map[string]pendingTotp // username → setup 暂存密钥
}
// NewAuthService 组装依赖。
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{
db: db, jwtSecret: []byte(jwtSecret),
guard: newLoginGuard(),
revoked: cache.New(4096),
totpPending: map[string]pendingTotp{},
}
}
// SetNotifier 注入锁定告警依赖;不注入(nil)则只锁定不推送。
func (s *AuthService) SetNotifier(n *Notifier, settings *SettingService) {
s.notifier = n
s.settings = settings
}
// SetCipher 注入加密组件(TOTP 密钥落库),main 启动时调用。
func (s *AuthService) SetCipher(c *crypto.Cipher) { s.cipher = c }
// EnsureAdmin 保证管理员账号可用:用户不存在时以给定密码创建;
// 已存在则不重置密码。库中无任何用户且未提供密码时报错,避免服务无法登录。
func (s *AuthService) EnsureAdmin(username, password string) error {
if password == "" {
var count int64
if err := s.db.Model(&model.User{}).Count(&count).Error; err != nil {
return fmt.Errorf("count users: %w", err)
}
if count == 0 {
return fmt.Errorf("ensure admin: no user exists, set ADMIN_PASSWORD to create one")
}
return nil
}
err := s.db.Where("username = ?", username).First(&model.User{}).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("find admin user: %w", err)
}
return s.createUser(username, password)
}
func (s *AuthService) createUser(username, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
user := &model.User{Username: username, PasswordHash: string(hash)}
if err := s.db.Create(user).Error; err != nil {
return fmt.Errorf("create user %s: %w", username, err)
}
return nil
}
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破,
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
key := clientIP + "|" + username
now := time.Now()
sec := securityOf(s.settings)
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
if s.guard.locked(key, now, lockFor) {
return "", time.Time{}, ErrLoginLocked
}
// 禁用密码登录后直接拒绝(不计失败);读取失败按未禁用处理,防配置故障锁死
if off, err := s.PasswordLoginDisabled(ctx); err == nil && off {
return "", time.Time{}, ErrPasswordLoginDisabled
}
var user model.User
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 与密码错误分支对齐耗时,防用户枚举
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
}
return "", time.Time{}, fmt.Errorf("find user: %w", err)
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
}
if user.TotpSecretEnc != "" {
if totpCode == "" {
return "", time.Time{}, ErrTotpRequired
}
if !s.verifyTotp(&user, totpCode) {
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
}
}
s.guard.success(key)
return s.signToken(user.Username)
}
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
func (s *AuthService) failLogin(key string, now time.Time, username, clientIP string, sec SecuritySettings) error {
if !s.guard.fail(key, now, sec.LoginFailLimit, time.Duration(sec.LoginLockMinutes)*time.Minute) {
return ErrInvalidCredentials
}
s.notifyLock(username, clientIP, sec)
return ErrLoginLocked
}
// notifyLock 异步推送登录锁定告警;notifier 未注入或开关关闭则跳过。
func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings) {
if s.notifier == nil {
return
}
if s.settings != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !s.settings.NotifyEventEnabled(ctx, "login_lock") {
return
}
}
s.notifier.SendTemplateAsync("login_lock", map[string]string{
"username": username, "ip": clientIP,
"fail_count": fmt.Sprintf("%d", sec.LoginFailLimit),
"lock_minutes": fmt.Sprintf("%d", sec.LoginLockMinutes),
})
}
func (s *AuthService) signToken(username string) (string, time.Time, error) {
now := time.Now()
expires := now.Add(tokenTTL)
claims := jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
if err != nil {
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
}
return token, expires, nil
}
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
func newTokenID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ParseToken 验证 JWT 签名与有效期,返回其中的用户名。
func (s *AuthService) ParseToken(tokenString string) (string, error) {
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return "", fmt.Errorf("parse token: %w", err)
}
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
return "", errors.New("token revoked")
}
return claims.Subject, nil
}
// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
func (s *AuthService) Logout(tokenString string) {
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
return s.jwtSecret, nil
})
if err != nil || claims.ExpiresAt == nil {
return
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return
}
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
}
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return "revoked|" + hex.EncodeToString(sum[:])
}
+163
View File
@@ -0,0 +1,163 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/model"
)
func newTestAuth(t *testing.T) *AuthService {
t.Helper()
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)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewAuthService(db, "test-jwt-secret")
}
func TestEnsureAdminCreatesUser(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", ""); err != nil {
t.Errorf("Login after EnsureAdmin: %v", err)
}
}
func TestEnsureAdminDoesNotResetPassword(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "first"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
if err := auth.EnsureAdmin("admin", "second"); err != nil {
t.Fatalf("EnsureAdmin twice: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "first", "127.0.0.1", ""); err != nil {
t.Errorf("Login with original password: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "second", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err)
}
}
func TestEnsureAdminNoPasswordNoUsers(t *testing.T) {
auth := newTestAuth(t)
err := auth.EnsureAdmin("admin", "")
if err == nil {
t.Fatal("EnsureAdmin with no password and no users: got nil error, want failure")
}
if !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
t.Errorf("error = %q, want mention of ADMIN_PASSWORD", err)
}
}
func TestEnsureAdminNoPasswordWithExistingUser(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
if err := auth.EnsureAdmin("admin", ""); err != nil {
t.Errorf("EnsureAdmin without password but user exists: %v", err)
}
}
func TestLoginFailures(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
tests := []struct {
name string
username string
password string
}{
{name: "密码错误", username: "admin", password: "wrong"},
{name: "用户不存在", username: "ghost", password: "pass123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := auth.Login(context.Background(), tt.username, tt.password, "127.0.0.1", "")
if !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err)
}
})
}
}
func TestTokenRoundTrip(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if expires.IsZero() {
t.Error("expires is zero, want future time")
}
username, err := auth.ParseToken(token)
if err != nil {
t.Fatalf("ParseToken: %v", err)
}
if got, want := username, "admin"; got != want {
t.Errorf("username = %q, want %q", got, want)
}
}
func TestParseTokenRejectsForged(t *testing.T) {
auth := newTestAuth(t)
other := newTestAuth(t)
other.jwtSecret = []byte("different-secret")
if err := other.EnsureAdmin("admin", "pass"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
forged, _, err := other.Login(context.Background(), "admin", "pass", "127.0.0.1", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if _, err := auth.ParseToken(forged); err == nil {
t.Error("ParseToken(forged): got nil error, want failure")
}
if _, err := auth.ParseToken("not.a.token"); err == nil {
t.Error("ParseToken(garbage): got nil error, want failure")
}
}
func TestLogoutRevokesToken(t *testing.T) {
auth := newTestAuth(t)
token, _, err := auth.signToken("admin")
if err != nil {
t.Fatalf("signToken: %v", err)
}
if _, err := auth.ParseToken(token); err != nil {
t.Fatalf("ParseToken before logout: %v", err)
}
auth.Logout(token)
if _, err := auth.ParseToken(token); err == nil {
t.Error("ParseToken after logout: got nil error, want revoked")
}
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
auth.Logout(token)
auth.Logout("not.a.token")
fresh, _, err := auth.signToken("admin")
if err != nil {
t.Fatalf("signToken fresh: %v", err)
}
if _, err := auth.ParseToken(fresh); err != nil {
t.Errorf("ParseToken(fresh) after revoking old: %v", err)
}
}
+26
View File
@@ -0,0 +1,26 @@
package service
import (
"context"
"oci-portal/internal/oci"
)
// Compartments 递归列出租户下全部 ACTIVE compartment(不含租户根)。
func (s *OciConfigService) Compartments(ctx context.Context, id uint) ([]oci.Compartment, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListCompartments(ctx, cred)
}
// credentialsInCompartment 取配置凭据并限定资源操作的目标 compartment(空为租户根)。
func (s *OciConfigService) credentialsInCompartment(ctx context.Context, id uint, compartmentID string) (oci.Credentials, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Credentials{}, err
}
cred.CompartmentID = compartmentID
return cred, nil
}
+43
View File
@@ -0,0 +1,43 @@
package service
import (
"context"
"fmt"
"strings"
"oci-portal/internal/oci"
)
// CreateConsoleConnection 为实例创建控制台连接(VNC / 串口)。
// OCI 控制台连接只接受 RSA 公钥,ed25519 等类型会被拒绝。
func (s *OciConfigService) CreateConsoleConnection(ctx context.Context, id uint, region, instanceID, sshPublicKey string) (oci.ConsoleConnection, error) {
if sshPublicKey == "" {
return oci.ConsoleConnection{}, fmt.Errorf("create console connection: sshPublicKey is required")
}
if !strings.HasPrefix(strings.TrimSpace(sshPublicKey), "ssh-rsa ") {
return oci.ConsoleConnection{}, fmt.Errorf("create console connection: only ssh-rsa public keys are supported by OCI console connections")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.ConsoleConnection{}, err
}
return s.client.CreateConsoleConnection(ctx, cred, region, instanceID, sshPublicKey)
}
// ConsoleConnections 列出实例的控制台连接。
func (s *OciConfigService) ConsoleConnections(ctx context.Context, id uint, region, instanceID string) ([]oci.ConsoleConnection, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListConsoleConnections(ctx, cred, region, instanceID)
}
// DeleteConsoleConnection 删除控制台连接。
func (s *OciConfigService) DeleteConsoleConnection(ctx context.Context, id uint, region, connectionID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteConsoleConnection(ctx, cred, region, connectionID)
}
+136
View File
@@ -0,0 +1,136 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
"oci-portal/internal/model"
)
// ErrCredentialConfirm 表示当前密码校验失败;api 层映射 401。
var ErrCredentialConfirm = errors.New("当前密码不正确")
// ErrCredentialInvalid 标记凭据输入非法;api 层映射 400。
var ErrCredentialInvalid = errors.New("凭据输入非法")
// ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。
var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用外部身份登录")
// ErrNeedIdentity 表示未绑定外部身份不可禁用密码登录;api 层映射 409。
var ErrNeedIdentity = errors.New("至少绑定一个外部身份后才能禁用密码登录")
// ErrLastIdentity 表示密码登录禁用期间不可解绑最后一个身份(防自锁);api 层映射 409。
var ErrLastIdentity = errors.New("密码登录已禁用,不能解绑最后一个外部身份;请先允许密码登录")
// UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。
type UpdateCredentialsInput struct {
NewUsername string `json:"newUsername"`
NewPassword string `json:"newPassword"`
CurrentPassword string `json:"currentPassword" binding:"required"`
}
// UpdateCredentials 修改用户名 / 密码:当前密码必验;改名后旧 JWT 的
// sub 不再命中账号,前端应强制重新登录。
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) error {
user, err := s.findUser(ctx, username)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil {
return ErrCredentialConfirm
}
newName := strings.TrimSpace(in.NewUsername)
if err := validateCredentialChange(user, newName, in.NewPassword); err != nil {
return err
}
updates := map[string]any{}
if newName != "" && newName != user.Username {
taken, err := s.usernameTaken(ctx, newName, user.ID)
if err != nil {
return err
}
if taken {
return fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
}
updates["username"] = newName
}
if in.NewPassword != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
updates["password_hash"] = string(hash)
}
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("update credentials: %w", err)
}
return nil
}
// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。
func validateCredentialChange(user *model.User, newName, newPassword string) error {
if newName != "" && len(newName) > 64 {
return fmt.Errorf("用户名最长 64 字符: %w", ErrCredentialInvalid)
}
// bcrypt 只取前 72 字节,超长部分静默截断,直接拒绝
if newPassword != "" && (len(newPassword) < 8 || len(newPassword) > 72) {
return fmt.Errorf("新密码长度须在 8-72 之间: %w", ErrCredentialInvalid)
}
if (newName == "" || newName == user.Username) && newPassword == "" {
return fmt.Errorf("没有需要保存的变更: %w", ErrCredentialInvalid)
}
return nil
}
func (s *AuthService) usernameTaken(ctx context.Context, name string, selfID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.User{}).
Where("username = ? AND id <> ?", name, selfID).Count(&count).Error
if err != nil {
return false, fmt.Errorf("check username: %w", err)
}
return count > 0, nil
}
// PasswordLoginDisabled 读密码登录禁用开关;settings 未注入(测试)视为未禁用。
func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
if s.settings == nil {
return false, nil
}
return s.settings.PasswordLoginDisabled(ctx)
}
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
if s.settings == nil {
return errors.New("settings unavailable")
}
if disabled {
user, err := s.findUser(ctx, username)
if err != nil {
return err
}
n, err := s.identityCount(ctx, user.ID)
if err != nil {
return err
}
if n == 0 {
return ErrNeedIdentity
}
}
return s.settings.SetPasswordLoginDisabled(ctx, disabled)
}
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.UserIdentity{}).
Where("user_id = ?", userID).Count(&count).Error
if err != nil {
return 0, fmt.Errorf("count identities: %w", err)
}
return count, nil
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"errors"
"testing"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// newCredEnv 复用 totp 环境并注入 settings(密码登录开关落 Setting 表)。
func newCredEnv(t *testing.T) *AuthService {
t.Helper()
auth, db := newTotpEnv(t)
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
auth.SetNotifier(nil, NewSettingService(db, cipher))
return auth
}
func TestUpdateCredentials(t *testing.T) {
auth := newCredEnv(t)
ctx := context.Background()
// 当前密码错误
err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"})
if !errors.Is(err, ErrCredentialConfirm) {
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
}
// 短密码 / 无变更均拒绝
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"})
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
}
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"})
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
}
// 同时改名改密
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
})
if err != nil {
t.Fatalf("UpdateCredentials: %v", err)
}
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("old username login: err = %v, want ErrInvalidCredentials", err)
}
if _, _, err := auth.Login(ctx, "root", "newpass-123", "127.0.0.2", ""); err != nil {
t.Errorf("new credentials login: %v", err)
}
}
func TestPasswordLoginToggle(t *testing.T) {
auth := newCredEnv(t)
ctx := context.Background()
// 未绑定外部身份不可禁用
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); !errors.Is(err, ErrNeedIdentity) {
t.Fatalf("disable without identity: err = %v, want ErrNeedIdentity", err)
}
var user model.User
if err := auth.db.Where("username = ?", "admin").First(&user).Error; err != nil {
t.Fatalf("find admin: %v", err)
}
ident := model.UserIdentity{UserID: user.ID, Provider: "github", Subject: "1", Display: "tester"}
if err := auth.db.Create(&ident).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); err != nil {
t.Fatalf("disable with identity: %v", err)
}
if off, err := auth.PasswordLoginDisabled(ctx); err != nil || !off {
t.Fatalf("PasswordLoginDisabled = %v, %v; want true", off, err)
}
// 密码登录被拒且不计失败(正确密码亦拒)
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrPasswordLoginDisabled) {
t.Fatalf("login while disabled: err = %v, want ErrPasswordLoginDisabled", err)
}
// 开关开着不能解绑最后一个身份
oauth := NewOAuthService(auth.db, nil, auth)
if err := oauth.Unbind(ctx, "admin", ident.ID); !errors.Is(err, ErrLastIdentity) {
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
}
// 恢复密码登录后可解绑、可登录
if err := auth.SetPasswordLoginDisabled(ctx, "admin", false); err != nil {
t.Fatalf("enable password login: %v", err)
}
if err := oauth.Unbind(ctx, "admin", ident.ID); err != nil {
t.Fatalf("unbind after enable: %v", err)
}
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.3", ""); err != nil {
t.Errorf("login after enable: %v", err)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package service 实现业务逻辑和本地快照同步。
package service
+146
View File
@@ -0,0 +1,146 @@
package service
import (
"context"
"fmt"
"strings"
"oci-portal/internal/oci"
)
// IdentityProviders 列出域内 SAML 身份提供者。
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint) ([]oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListIdentityProviders(ctx, cred, homeRegion)
}
// CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。
// 映射与 JIT 字段缺省时填控制台默认值(normalizeIdpInput)。
func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
if strings.TrimSpace(in.Name) == "" {
return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: name is required")
}
if !strings.Contains(in.Metadata, "EntityDescriptor") {
return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: metadata must be a SAML metadata XML")
}
// IDCS 校验 iconUrl 必须是 http(s) URLdata URI 会被 400 拒绝
if in.IconURL != "" && !strings.HasPrefix(in.IconURL, "http://") && !strings.HasPrefix(in.IconURL, "https://") {
return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: iconUrl 需为 http(s) 外链图片 URL")
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, normalizeIdpInput(in))
}
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
// 目标域用户属性「用户名」(JIT 开关组合由 API 层的指针缺省决定)。
func normalizeIdpInput(in oci.CreateIdpInput) oci.CreateIdpInput {
if in.NameIDFormat == "" {
in.NameIDFormat = "saml-none"
}
if in.UserStoreAttribute == "" {
in.UserStoreAttribute = "userName"
}
return in
}
// SetIdentityProviderEnabled 激活/停用 IdP 并同步登录页显示。
func (s *OciConfigService) SetIdentityProviderEnabled(ctx context.Context, id uint, idpID string, enabled bool) (oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.SetIdentityProviderEnabled(ctx, cred, homeRegion, idpID, enabled)
}
// DeleteIdentityProvider 级联删除 IdP:先删关联的免 MFA 规则,再从登录页
// 移除、停用、删除 IdP 本体。
func (s *OciConfigService) DeleteIdentityProvider(ctx context.Context, id uint, idpID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
if err := s.deleteIdpExemptions(ctx, cred, homeRegion, idpID); err != nil {
return err
}
return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, idpID)
}
// deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。
func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, idpID string) error {
rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region)
if err != nil {
return fmt.Errorf("list sign-on rules before delete idp: %w", err)
}
for _, r := range rules {
if r.BuiltIn || !strings.Contains(r.ConditionValue, idpID) {
continue
}
if err := s.client.DeleteMfaExemptionRule(ctx, cred, region, r.ID); err != nil {
return fmt.Errorf("delete exemption rule %s: %w", r.Name, err)
}
}
return nil
}
// DomainSamlMetadata 下载域的 SP SAML 元数据 XML(提供给 IdP 侧配置)。
func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint) ([]byte, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion)
}
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint) ([]oci.SignOnRuleInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion)
}
// CreateMfaExemption 为指定 IdP 创建免 MFA 规则并置为最高优先级。
func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, idpID string) (oci.SignOnRuleInfo, error) {
if strings.TrimSpace(idpID) == "" {
return oci.SignOnRuleInfo{}, fmt.Errorf("create mfa exemption: identityProviderId is required")
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.SignOnRuleInfo{}, err
}
name, err := s.exemptionRuleName(ctx, cred, homeRegion, idpID)
if err != nil {
return oci.SignOnRuleInfo{}, err
}
return s.client.CreateMfaExemptionRule(ctx, cred, homeRegion, idpID, name)
}
// exemptionRuleName 校验 IdP 存在并用其名称生成规则名。
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, idpID string) (string, error) {
idps, err := s.client.ListIdentityProviders(ctx, cred, region)
if err != nil {
return "", err
}
for _, idp := range idps {
if idp.ID == idpID {
return "skip-mfa-" + idp.Name, nil
}
}
return "", fmt.Errorf("create mfa exemption: identity provider %s not found", idpID)
}
// DeleteMfaExemption 删除免 MFA 规则并恢复其余规则优先级。
func (s *OciConfigService) DeleteMfaExemption(ctx context.Context, id uint, ruleID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, ruleID)
}
+196
View File
@@ -0,0 +1,196 @@
package service
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"oci-portal/internal/oci"
)
// AvailabilityDomains 列出区域内可用域。
func (s *OciConfigService) AvailabilityDomains(ctx context.Context, id uint, region string) ([]string, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListAvailabilityDomains(ctx, cred, region)
}
// Instances 列出实例(含 IP 补全);compartmentID 为空表示租户根。
func (s *OciConfigService) Instances(ctx context.Context, id uint, region, compartmentID string) ([]oci.Instance, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListInstances(ctx, cred, region)
}
// maxBatchCreate 是单次批量创建实例的数量上限。
const maxBatchCreate = 10
// CreateInstances 批量创建实例,逐台尝试互不影响;count 大于 1 时
// 名称自动追加 -1、-2 等序号。返回成功实例与逐台失败信息。
// in.CompartmentID 为空时建在租户根。
func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.CreateInstanceInput, count int) ([]oci.Instance, []string, error) {
if count < 1 || count > maxBatchCreate {
return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate)
}
if err := validateCreateInstance(in); err != nil {
return nil, nil, err
}
cred, err := s.credentialsInCompartment(ctx, id, in.CompartmentID)
if err != nil {
return nil, nil, err
}
instances := make([]oci.Instance, 0, count)
var failures []string
for i := 1; i <= count; i++ {
one := in
if count > 1 {
one.DisplayName = fmt.Sprintf("%s-%d", in.DisplayName, i)
}
if err := applyGeneratedRootPassword(&one); err != nil {
return nil, nil, err
}
instance, err := s.client.LaunchInstance(ctx, cred, one)
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err)))
continue
}
instances = append(instances, instance)
}
return instances, failures, nil
}
// applyGeneratedRootPassword 在开启随机密码时为单台实例生成 root 密码,
// 同时写入 FreeformTags["RootPassword"] 便于面板回显;批量创建时每台独立。
func applyGeneratedRootPassword(one *oci.CreateInstanceInput) error {
if !one.GenerateRootPassword {
return nil
}
pwd, err := randomPassword(16)
if err != nil {
return fmt.Errorf("generate root password: %w", err)
}
one.RootPassword = pwd
tags := make(map[string]string, len(one.FreeformTags)+1)
for k, v := range one.FreeformTags {
tags[k] = v
}
tags["RootPassword"] = pwd
one.FreeformTags = tags
return nil
}
// randomPassword 生成含大小写字母、数字与安全符号的随机密码;
// 字符集避开引号、冒号等会破坏 cloud-init chpasswd 语法的字符。
func randomPassword(length int) (string, error) {
const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789@#%^*-_=+"
out := make([]byte, length)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
out[i] = charset[n.Int64()]
}
return string(out), nil
}
func validateCreateInstance(in oci.CreateInstanceInput) error {
switch {
case in.DisplayName == "":
return fmt.Errorf("create instance: displayName is required")
case in.Shape == "":
return fmt.Errorf("create instance: shape is required")
case in.ImageID == "" && in.BootVolumeID == "":
return fmt.Errorf("create instance: imageId or bootVolumeId is required")
case in.ImageID != "" && in.BootVolumeID != "":
return fmt.Errorf("create instance: imageId and bootVolumeId are mutually exclusive")
case in.SSHPublicKey != "" && in.RootPassword != "":
return fmt.Errorf("create instance: sshPublicKey and rootPassword are mutually exclusive")
case in.GenerateRootPassword && (in.SSHPublicKey != "" || in.RootPassword != ""):
return fmt.Errorf("create instance: generateRootPassword conflicts with sshPublicKey/rootPassword")
}
return nil
}
// Instance 查询单个实例(含 IP 补全)。
func (s *OciConfigService) Instance(ctx context.Context, id uint, region, instanceID string) (oci.Instance, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Instance{}, err
}
return s.client.GetInstance(ctx, cred, region, instanceID)
}
// UpdateInstance 更新实例名称、shape 或 Flex 规格。
func (s *OciConfigService) UpdateInstance(ctx context.Context, id uint, instanceID string, in oci.UpdateInstanceInput) (oci.Instance, error) {
if in.DisplayName == "" && in.Shape == "" && in.Ocpus == 0 {
return oci.Instance{}, fmt.Errorf("update instance: nothing to update")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Instance{}, err
}
return s.client.UpdateInstance(ctx, cred, instanceID, in)
}
// TerminateInstance 终止实例;preserveBootVolume 为 true 时保留引导卷。
func (s *OciConfigService) TerminateInstance(ctx context.Context, id uint, region, instanceID string, preserveBootVolume bool) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.TerminateInstance(ctx, cred, region, instanceID, preserveBootVolume)
}
// InstanceAction 执行电源操作:START/STOP/RESET/SOFTSTOP/SOFTRESET。
func (s *OciConfigService) InstanceAction(ctx context.Context, id uint, region, instanceID, action string) (oci.Instance, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Instance{}, err
}
return s.client.InstanceAction(ctx, cred, region, instanceID, action)
}
// BootVolumes 列出引导卷;compartmentID 为空表示租户根。
func (s *OciConfigService) BootVolumes(ctx context.Context, id uint, region, availabilityDomain, compartmentID string) ([]oci.BootVolume, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListBootVolumes(ctx, cred, region, availabilityDomain)
}
// BootVolume 查询引导卷详情(含挂载实例)。
func (s *OciConfigService) BootVolume(ctx context.Context, id uint, region, bootVolumeID string) (oci.BootVolume, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.BootVolume{}, err
}
return s.client.GetBootVolume(ctx, cred, region, bootVolumeID)
}
// UpdateBootVolume 更新引导卷名称、容量或性能。
func (s *OciConfigService) UpdateBootVolume(ctx context.Context, id uint, bootVolumeID string, in oci.UpdateBootVolumeInput) (oci.BootVolume, error) {
if in.DisplayName == "" && in.SizeInGBs == 0 && in.VpusPerGB == 0 {
return oci.BootVolume{}, fmt.Errorf("update boot volume: nothing to update")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.BootVolume{}, err
}
return s.client.UpdateBootVolume(ctx, cred, bootVolumeID, in)
}
// DeleteBootVolume 删除引导卷。
func (s *OciConfigService) DeleteBootVolume(ctx context.Context, id uint, region, bootVolumeID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteBootVolume(ctx, cred, region, bootVolumeID)
}
+178
View File
@@ -0,0 +1,178 @@
package service
import (
"context"
"strings"
"testing"
"oci-portal/internal/oci"
)
func TestCreateInstanceValidation(t *testing.T) {
valid := oci.CreateInstanceInput{
AvailabilityDomain: "AD-1",
DisplayName: "vm1",
Shape: "VM.Standard.A1.Flex",
ImageID: "ocid1.image..a",
SubnetID: "ocid1.subnet..s",
}
tests := []struct {
name string
mutate func(*oci.CreateInstanceInput)
wantErr string
}{
{name: "合法输入", mutate: func(*oci.CreateInstanceInput) {}},
{name: "缺名称", mutate: func(in *oci.CreateInstanceInput) { in.DisplayName = "" }, wantErr: "displayName"},
{name: "可用域可空(默认 ad-1)", mutate: func(in *oci.CreateInstanceInput) { in.AvailabilityDomain = "" }},
{name: "缺 shape", mutate: func(in *oci.CreateInstanceInput) { in.Shape = "" }, wantErr: "shape"},
{name: "缺启动源", mutate: func(in *oci.CreateInstanceInput) { in.ImageID = "" }, wantErr: "imageId or bootVolumeId"},
{name: "启动源互斥", mutate: func(in *oci.CreateInstanceInput) { in.BootVolumeID = "bv" }, wantErr: "mutually exclusive"},
{name: "子网可空(自动建网)", mutate: func(in *oci.CreateInstanceInput) { in.SubnetID = "" }},
{name: "密钥与密码互斥", mutate: func(in *oci.CreateInstanceInput) {
in.SSHPublicKey = "ssh-ed25519 AAA"
in.RootPassword = "pw"
}, wantErr: "sshPublicKey and rootPassword"},
{name: "随机密码与密钥互斥", mutate: func(in *oci.CreateInstanceInput) {
in.GenerateRootPassword = true
in.SSHPublicKey = "ssh-ed25519 AAA"
}, wantErr: "generateRootPassword"},
{name: "随机密码与固定密码互斥", mutate: func(in *oci.CreateInstanceInput) {
in.GenerateRootPassword = true
in.RootPassword = "pw"
}, wantErr: "generateRootPassword"},
}
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := valid
tt.mutate(&in)
_, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, 1)
if tt.wantErr == "" {
if err != nil {
t.Errorf("CreateInstances: %v, want success", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("CreateInstances error = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestCreateInstancesBatch(t *testing.T) {
client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
in := oci.CreateInstanceInput{DisplayName: "vm", Shape: "VM.Standard.A1.Flex", ImageID: "img"}
for _, count := range []int{0, 11} {
if _, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, count); err == nil {
t.Errorf("count=%d: got nil error, want out-of-range failure", count)
}
}
instances, failures, err := svc.CreateInstances(context.Background(), cfg.ID, in, 3)
if err != nil {
t.Fatalf("CreateInstances: %v", err)
}
if len(failures) != 0 {
t.Errorf("failures = %v, want none", failures)
}
got := make([]string, 0, len(instances))
for _, inst := range instances {
got = append(got, inst.DisplayName)
}
want := []string{"vm-1", "vm-2", "vm-3"}
if len(got) != len(want) {
t.Fatalf("created %d instances %v, want %v", len(got), got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("instance %d name = %q, want %q", i, got[i], want[i])
}
}
}
func TestCreateInstancesGeneratedRootPassword(t *testing.T) {
client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
in := oci.CreateInstanceInput{
DisplayName: "vm",
Shape: "VM.Standard.A1.Flex",
ImageID: "img",
GenerateRootPassword: true,
FreeformTags: map[string]string{"env": "prod"},
}
if _, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, 2); err != nil {
t.Fatalf("CreateInstances: %v", err)
}
if len(client.launched) != 2 {
t.Fatalf("launched %d instances, want 2", len(client.launched))
}
pwds := map[string]bool{}
for i, got := range client.launched {
if got.RootPassword == "" {
t.Errorf("instance %d: RootPassword empty, want generated", i)
}
if got.FreeformTags["RootPassword"] != got.RootPassword {
t.Errorf("instance %d: tag RootPassword = %q, want %q", i, got.FreeformTags["RootPassword"], got.RootPassword)
}
if got.FreeformTags["env"] != "prod" {
t.Errorf("instance %d: tag env = %q, want prod (原有标签须保留)", i, got.FreeformTags["env"])
}
pwds[got.RootPassword] = true
}
if len(pwds) != 2 {
t.Errorf("批量创建密码应各台独立, got %d 个不同密码", len(pwds))
}
if in.FreeformTags["RootPassword"] != "" {
t.Error("原始输入 FreeformTags 被修改, 应复制后再写入")
}
}
func TestUpdateInstanceRequiresChange(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if _, err := svc.UpdateInstance(context.Background(), cfg.ID, "ocid1.instance..i", oci.UpdateInstanceInput{}); err == nil {
t.Error("UpdateInstance with empty input: got nil error, want failure")
}
}
func TestCreateConsoleConnectionValidation(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
tests := []struct {
name string
key string
}{
{name: "空公钥", key: ""},
{name: "非 RSA 公钥", key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := svc.CreateConsoleConnection(context.Background(), cfg.ID, "", "ocid1.instance..i", tt.key); err == nil {
t.Error("CreateConsoleConnection: got nil error, want failure")
}
})
}
}
func TestDeleteInstanceIpv6RequiresAddress(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if err := svc.DeleteInstanceIpv6(context.Background(), cfg.ID, "", "ocid1.instance..i", ""); err == nil {
t.Error("DeleteInstanceIpv6 with empty address: got nil error, want failure")
}
}
func TestUpdateBootVolumeRequiresChange(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if _, err := svc.UpdateBootVolume(context.Background(), cfg.ID, "ocid1.bootvolume..b", oci.UpdateBootVolumeInput{}); err == nil {
t.Error("UpdateBootVolume with empty input: got nil error, want failure")
}
}
+467
View File
@@ -0,0 +1,467 @@
package service
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// 日志回传的保留策略与后台节奏(方案A §2.4/§3)。
const (
logEventRetention = 90 * 24 * time.Hour // 过期删除阈值
logEventMaxRows = 20000 // 总量兜底上限(Payload 大,低于系统日志的 5 万)
logEventCleanupTick = 24 * time.Hour // 周期清理间隔
logEventParseTick = 30 * time.Second // 解析器轮询间隔
logEventParseBatch = 200 // 单轮解析行数上限
logEventConfirmWait = 10 * time.Second // 订阅确认 GET 超时
)
// 日志回传查询分页默认值与上限。
const (
logEventDefaultPageSize = 20
logEventMaxPageSize = 100
)
// logWebhookSecretPrefix 是每租户回传 secret 的 Setting 键前缀,后接 cfgID。
const logWebhookSecretPrefix = "log_webhook_secret:"
// LogEventService 承接 OCI 日志回传:secret 管理、事件入库、异步解析与清理,
// 以及 P1 引导创建(SetRelayDeps 注入)与 P2 告警联动(SetNotifier 注入)。
type LogEventService struct {
db *gorm.DB
wg sync.WaitGroup
confirm func(ctx context.Context, url string) error // 订阅确认 GET,测试可注入
configs *OciConfigService // 凭据来源(P1)
relayClient oci.Client // 云端链路操作(P1)
publicURL string // 面板公网基址,拼接回调 endpoint(P1)
notifier *Notifier // 关键事件推送(P2)
settings *SettingService // log_event 事件开关(P2)
relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认
relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认
}
// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。
func NewLogEventService(db *gorm.DB) *LogEventService {
s := &LogEventService{db: db}
s.confirm = s.confirmSubscription
return s
}
// secretKey 拼接指定配置的 Setting 键。
func secretKey(cfgID uint) string {
return fmt.Sprintf("%s%d", logWebhookSecretPrefix, cfgID)
}
// LogWebhookInfo 是回传回调地址视图;Path 供前端以公网域名拼接完整 URL。
type LogWebhookInfo struct {
Path string `json:"path"`
Secret string `json:"secret"`
CreatedAt time.Time `json:"createdAt"`
}
// EnsureSecret 为配置生成(或幂等返回)回传 secret。
func (s *LogEventService) EnsureSecret(ctx context.Context, cfgID uint) (LogWebhookInfo, error) {
if err := s.requireConfig(ctx, cfgID); err != nil {
return LogWebhookInfo{}, err
}
if info, ok, err := s.SecretInfo(ctx, cfgID); err != nil || ok {
return info, err
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return LogWebhookInfo{}, fmt.Errorf("generate webhook secret: %w", err)
}
secret := hex.EncodeToString(buf)
st := model.Setting{Key: secretKey(cfgID), Value: secret, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
return LogWebhookInfo{}, fmt.Errorf("save webhook secret: %w", err)
}
return webhookInfo(secret, st.UpdatedAt), nil
}
// requireConfig 校验配置存在,不存在透传 gorm.ErrRecordNotFound(api 层映射 404)。
func (s *LogEventService) requireConfig(ctx context.Context, cfgID uint) error {
var cfg model.OciConfig
if err := s.db.WithContext(ctx).Select("id").First(&cfg, cfgID).Error; err != nil {
return fmt.Errorf("find oci config %d: %w", cfgID, err)
}
return nil
}
// SecretInfo 查询配置是否已生成 secret;未生成时 ok 为 false。
func (s *LogEventService) SecretInfo(ctx context.Context, cfgID uint) (LogWebhookInfo, bool, error) {
var st model.Setting
err := s.db.WithContext(ctx).First(&st, "key = ?", secretKey(cfgID)).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return LogWebhookInfo{}, false, nil
}
return LogWebhookInfo{}, false, fmt.Errorf("get webhook secret: %w", err)
}
if st.Value == "" {
return LogWebhookInfo{}, false, nil
}
return webhookInfo(st.Value, st.UpdatedAt), true, nil
}
// webhookInfo 由 secret 组装回调地址视图。
func webhookInfo(secret string, at time.Time) LogWebhookInfo {
return LogWebhookInfo{
Path: "/api/v1/webhooks/oci-logs/" + secret,
Secret: secret,
CreatedAt: at,
}
}
// RevokeSecret 撤销配置的回传 secret;之后旧回调地址一律 404。
func (s *LogEventService) RevokeSecret(ctx context.Context, cfgID uint) error {
err := s.db.WithContext(ctx).Delete(&model.Setting{}, "key = ?", secretKey(cfgID)).Error
if err != nil {
return fmt.Errorf("revoke webhook secret: %w", err)
}
return nil
}
// ResolveSecret 由 secret 反查归属配置;比对使用常数时间比较,防时序侧信道。
func (s *LogEventService) ResolveSecret(ctx context.Context, secret string) (uint, bool) {
if secret == "" {
return 0, false
}
var rows []model.Setting
err := s.db.WithContext(ctx).
Where("key LIKE ?", logWebhookSecretPrefix+"%").Find(&rows).Error
if err != nil {
log.Printf("resolve webhook secret: %v", err)
return 0, false
}
for _, row := range rows {
if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) == 1 {
id, err := strconv.ParseUint(strings.TrimPrefix(row.Key, logWebhookSecretPrefix), 10, 64)
if err != nil {
return 0, false
}
return uint(id), true
}
}
return 0, false
}
// Ingest 落一条回传事件;MessageID 唯一索引冲突即静默忽略(at-least-once 幂等)。
func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
event := model.LogEvent{
OciConfigID: cfgID,
MessageID: messageID,
Payload: string(payload),
Truncated: truncated,
ReceivedAt: time.Now(),
}
err := s.db.WithContext(ctx).
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true}).
Create(&event).Error
if err != nil {
return fmt.Errorf("ingest log event: %w", err)
}
return nil
}
// LogEventQuery 是回传事件查询参数;CfgID 为 0 表示全部租户。
type LogEventQuery struct {
CfgID uint
Page int
PageSize int
}
// normalize 补分页默认值并钳制上限。
func (q LogEventQuery) normalize() LogEventQuery {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 {
q.PageSize = logEventDefaultPageSize
}
if q.PageSize > logEventMaxPageSize {
q.PageSize = logEventMaxPageSize
}
return q
}
// List 按接收顺序倒序分页查询回传事件。
func (s *LogEventService) List(ctx context.Context, q LogEventQuery) ([]model.LogEvent, int64, error) {
q = q.normalize()
tx := s.db.WithContext(ctx).Model(&model.LogEvent{})
if q.CfgID > 0 {
tx = tx.Where("oci_config_id = ?", q.CfgID)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count log events: %w", err)
}
items := make([]model.LogEvent, 0, q.PageSize)
err := tx.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&items).Error
if err != nil {
return nil, 0, fmt.Errorf("list log events: %w", err)
}
return items, total, nil
}
// Wait 等待在途后台协程(确认/解析/清理)退出,供进程收尾与测试同步。
func (s *LogEventService) Wait() {
s.wg.Wait()
}
// ConfirmAsync 异步 GET 订阅确认链接完成激活;失败仅记系统日志可人工重发,
// URL 白名单校验由 api 层完成,此处不再信任外部输入以外的假设。
func (s *LogEventService) ConfirmAsync(confirmURL string) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), logEventConfirmWait)
defer cancel()
if err := s.confirm(ctx, confirmURL); err != nil {
log.Printf("confirm ons subscription: %v", err)
}
}()
}
// confirmSubscription 执行确认 GET;重定向只允许留在 Oracle 域内,响应体丢弃。
func (s *LogEventService) confirmSubscription(ctx context.Context, confirmURL string) error {
client := &http.Client{
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
if !IsOracleHost(req.URL.Host) {
return fmt.Errorf("redirect outside oracle domain")
}
return nil
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, confirmURL, nil)
if err != nil {
return fmt.Errorf("build confirm request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("confirm request: %w", sanitizeURLError(err))
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("confirm request: status %d", resp.StatusCode)
}
return nil
}
// IsOracleHost 判定 host 是否属于 Oracle 云域(订阅确认/验签证书源白名单)。
func IsOracleHost(host string) bool {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.ToLower(host)
return host == "oraclecloud.com" || strings.HasSuffix(host, ".oraclecloud.com")
}
// StartParser 启动解析协程:周期消费未解析事件,回填类型/来源/事件时间。
func (s *LogEventService) StartParser(ctx context.Context) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(logEventParseTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.parseOnce(ctx)
}
}
}()
}
// parseOnce 消费一批未解析事件;失败只记日志,不中断周期调度。
func (s *LogEventService) parseOnce(ctx context.Context) {
var events []model.LogEvent
err := s.db.WithContext(ctx).
Where("processed = ?", false).Order("id").Limit(logEventParseBatch).
Find(&events).Error
if err != nil {
log.Printf("log event parse load: %v", err)
return
}
for i := range events {
e := &events[i]
parsed := parseLogEvent([]byte(e.Payload))
e.EventType, e.Source, e.SourceIP, e.EventTime =
parsed.EventType, parsed.Source, parsed.SourceIP, parsed.EventTime
e.Processed = true
if err := s.db.WithContext(ctx).Save(e).Error; err != nil {
log.Printf("log event parse save %d: %v", e.ID, err)
return
}
s.notifyCritical(ctx, e, parsed)
}
}
// 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"`
}
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
type onsIdentity struct {
IPAddress string `json:"ipAddress"`
}
// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。
type parsedEvent struct {
EventType string
Source string
SourceIP string
ResourceName string
EventTime *time.Time
}
// parseLogEvent 从消息原文宽松提取事件字段;非 JSON 返回零值。
func parseLogEvent(payload []byte) parsedEvent {
var env onsEnvelope
if err := json.Unmarshal(payload, &env); err != nil {
var batch []onsEnvelope
if err := json.Unmarshal(payload, &batch); err != nil || len(batch) == 0 {
return parsedEvent{}
}
env = batch[0]
}
if len(env.Data) > 0 {
var inner onsEnvelope
if err := json.Unmarshal(env.Data, &inner); err == nil {
return envelopeFields(mergeEnvelope(env, inner))
}
}
return envelopeFields(env)
}
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
if outer.EventType == "" {
outer.EventType = inner.EventType
}
if outer.Type == "" {
outer.Type = inner.Type
}
if outer.Source == "" {
outer.Source = inner.Source
}
if outer.EventTime == "" {
outer.EventTime = inner.EventTime
}
if outer.ResourceName == "" {
outer.ResourceName = inner.ResourceName
}
if outer.Identity == nil {
outer.Identity = inner.Identity
}
return outer
}
// envelopeFields 收敛字段别名并解析事件时间。
func envelopeFields(env onsEnvelope) parsedEvent {
eventType := env.EventType
if eventType == "" {
eventType = env.Type
}
var eventTime *time.Time
if env.EventTime != "" {
if ts, err := time.Parse(time.RFC3339, env.EventTime); err == nil {
eventTime = &ts
}
}
ip := ""
if env.Identity != nil {
ip = env.Identity.IPAddress
}
return parsedEvent{
EventType: clip(eventType, 128),
Source: clip(env.Source, 64),
SourceIP: clip(ip, 64),
ResourceName: clip(env.ResourceName, 128),
EventTime: eventTime,
}
}
// clip 按模型列宽截断解析出的字段。
func clip(s string, max int) string {
if len(s) > max {
return s[:max]
}
return s
}
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
func (s *LogEventService) StartCleanup(ctx context.Context) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.cleanupOnce(ctx)
ticker := time.NewTicker(logEventCleanupTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupOnce(ctx)
}
}
}()
}
// cleanupOnce 执行一轮清理,失败只记日志、不中断周期调度。
func (s *LogEventService) cleanupOnce(ctx context.Context) {
if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil {
log.Printf("log event cleanup: %v", err)
}
}
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
func (s *LogEventService) cleanup(ctx context.Context, retention time.Duration, maxRows int) error {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("received_at < ?", cutoff).Delete(&model.LogEvent{}).Error; err != nil {
return fmt.Errorf("delete expired log events: %w", err)
}
var total int64
if err := s.db.WithContext(ctx).Model(&model.LogEvent{}).Count(&total).Error; err != nil {
return fmt.Errorf("count log events: %w", err)
}
overflow := int(total) - maxRows
if overflow <= 0 {
return nil
}
oldest := s.db.Model(&model.LogEvent{}).Select("id").Order("id ASC").Limit(overflow)
if err := s.db.WithContext(ctx).Where("id IN (?)", oldest).Delete(&model.LogEvent{}).Error; err != nil {
return fmt.Errorf("trim log events over cap: %w", err)
}
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/model"
)
// newLogEventEnv 建含回传所需表的内存库环境,并预置一个 OCI 配置。
func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) {
t.Helper()
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.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cfg := model.OciConfig{Alias: "测试租户"}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
return NewLogEventService(db), db, cfg.ID
}
func TestEnsureAndResolveSecret(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
other := model.OciConfig{Alias: "另一租户"}
if err := db.Create(&other).Error; err != nil {
t.Fatalf("create config: %v", err)
}
first, err := svc.EnsureSecret(ctx, cfgID)
if err != nil {
t.Fatalf("ensure secret: %v", err)
}
if len(first.Secret) != 64 {
t.Fatalf("secret length = %d, want 64", len(first.Secret))
}
if want := "/api/v1/webhooks/oci-logs/" + first.Secret; first.Path != want {
t.Errorf("path = %s, want %s", first.Path, want)
}
again, err := svc.EnsureSecret(ctx, cfgID)
if err != nil || again.Secret != first.Secret {
t.Errorf("ensure not idempotent: %v, secret changed %v", err, again.Secret != first.Secret)
}
otherInfo, err := svc.EnsureSecret(ctx, other.ID)
if err != nil {
t.Fatalf("ensure other secret: %v", err)
}
tests := []struct {
name string
secret string
wantID uint
wantHit bool
}{
{name: "命中本租户", secret: first.Secret, wantID: cfgID, wantHit: true},
{name: "命中另一租户", secret: otherInfo.Secret, wantID: other.ID, wantHit: true},
{name: "未知 secret 不命中", secret: "deadbeef", wantHit: false},
{name: "空 secret 不命中", secret: "", wantHit: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, ok := svc.ResolveSecret(ctx, tt.secret)
if ok != tt.wantHit || id != tt.wantID {
t.Errorf("resolve = (%d,%v), want (%d,%v)", id, ok, tt.wantID, tt.wantHit)
}
})
}
if err := svc.RevokeSecret(ctx, cfgID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := svc.ResolveSecret(ctx, first.Secret); ok {
t.Error("revoked secret still resolves")
}
if _, exists, _ := svc.SecretInfo(ctx, cfgID); exists {
t.Error("secret info exists after revoke")
}
}
func TestEnsureSecretRejectsUnknownConfig(t *testing.T) {
svc, _, _ := newLogEventEnv(t)
if _, err := svc.EnsureSecret(context.Background(), 9999); err == nil {
t.Fatal("ensure secret for unknown config succeeded, want error")
}
}
func TestIngestIdempotent(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := svc.Ingest(ctx, cfgID, "msg-1", []byte(`{"eventType":"t"}`), false); err != nil {
t.Fatalf("ingest #%d: %v", i+1, err)
}
}
if err := svc.Ingest(ctx, cfgID, "msg-2", []byte("cut"), true); err != nil {
t.Fatalf("ingest truncated: %v", err)
}
var total int64
db.Model(&model.LogEvent{}).Count(&total)
if total != 2 {
t.Errorf("rows = %d, want 2(重复 messageId 不落新行)", total)
}
var truncated model.LogEvent
db.First(&truncated, "message_id = ?", "msg-2")
if !truncated.Truncated {
t.Error("truncated flag not persisted")
}
}
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: "CloudEvents 单事件",
payload: `{"eventType":"com.oraclecloud.ComputeApi.TerminateInstance","source":"ComputeApi","eventTime":"` + ts + `"}`,
wantType: "com.oraclecloud.ComputeApi.TerminateInstance",
wantSource: "ComputeApi",
wantTime: true,
},
{
name: "type 别名",
payload: `{"type":"custom.event"}`,
wantType: "custom.event",
},
{
name: "外层缺失取 data 内层",
payload: `{"data":{"eventType":"inner.event","source":"Audit"}}`,
wantType: "inner.event",
wantSource: "Audit",
},
{
name: "数组取首个",
payload: `[{"eventType":"batch.first"},{"eventType":"batch.second"}]`,
wantType: "batch.first",
},
{name: "非 JSON 全空", payload: "plain text message"},
{name: "时间非法只丢时间", payload: `{"eventType":"x","eventTime":"not-a-time"}`, wantType: "x"},
{
name: "Audit 事件提取 data.identity.ipAddress",
payload: `{"eventType":"com.oraclecloud.limits.ListLimitValues","data":{"identity":{"ipAddress":"203.0.113.9"}}}`,
wantType: "com.oraclecloud.limits.ListLimitValues",
wantIP: "203.0.113.9",
},
{
name: "identity 缺失 IP 为空",
payload: `{"eventType":"x","data":{"identity":{}}}`,
wantType: "x",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseLogEvent([]byte(tt.payload))
if got.EventType != tt.wantType || got.Source != tt.wantSource || got.SourceIP != tt.wantIP {
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
}
if (got.EventTime != nil) != tt.wantTime {
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
}
})
}
}
func TestParseOnceMarksProcessed(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
if err := svc.Ingest(ctx, cfgID, "m-json", []byte(`{"eventType":"a.b","source":"S"}`), false); err != nil {
t.Fatalf("ingest: %v", err)
}
if err := svc.Ingest(ctx, cfgID, "m-text", []byte("opaque"), false); err != nil {
t.Fatalf("ingest: %v", err)
}
svc.parseOnce(ctx)
var parsed, opaque model.LogEvent
db.First(&parsed, "message_id = ?", "m-json")
db.First(&opaque, "message_id = ?", "m-text")
if !parsed.Processed || parsed.EventType != "a.b" || parsed.Source != "S" {
t.Errorf("parsed = %+v, want processed with type/source", parsed)
}
if !opaque.Processed || opaque.EventType != "" {
t.Errorf("opaque = %+v, want processed without fields", opaque)
}
}
func TestLogEventCleanup(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
old := model.LogEvent{OciConfigID: cfgID, MessageID: "old", ReceivedAt: time.Now().Add(-100 * 24 * time.Hour)}
db.Create(&old)
for i := 0; i < 4; i++ {
db.Create(&model.LogEvent{
OciConfigID: cfgID, MessageID: fmt.Sprintf("new-%d", i), ReceivedAt: time.Now(),
})
}
if err := svc.cleanup(ctx, logEventRetention, 2); err != nil {
t.Fatalf("cleanup: %v", err)
}
var total int64
db.Model(&model.LogEvent{}).Count(&total)
if total != 2 {
t.Errorf("rows after cleanup = %d, want 2(过期删除+超量删最旧)", total)
}
var gone model.LogEvent
if err := db.First(&gone, "message_id = ?", "old").Error; err == nil {
t.Error("expired row survived cleanup")
}
}
func TestLogEventListFilters(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
other := model.OciConfig{Alias: "旁租户"}
db.Create(&other)
for i := 0; i < 3; i++ {
db.Create(&model.LogEvent{OciConfigID: cfgID, MessageID: fmt.Sprintf("a-%d", i), ReceivedAt: time.Now()})
}
db.Create(&model.LogEvent{OciConfigID: other.ID, MessageID: "b-0", ReceivedAt: time.Now()})
tests := []struct {
name string
query LogEventQuery
wantTotal int64
wantLen int
wantFirst string
}{
{name: "全部倒序", query: LogEventQuery{}, wantTotal: 4, wantLen: 4, wantFirst: "b-0"},
{name: "按租户过滤", query: LogEventQuery{CfgID: cfgID}, wantTotal: 3, wantLen: 3, wantFirst: "a-2"},
{name: "分页第二页", query: LogEventQuery{CfgID: cfgID, Page: 2, PageSize: 2}, wantTotal: 3, wantLen: 1, wantFirst: "a-0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items, total, err := svc.List(ctx, tt.query)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != tt.wantTotal || len(items) != tt.wantLen {
t.Fatalf("total=%d len=%d, want %d/%d", total, len(items), tt.wantTotal, tt.wantLen)
}
if items[0].MessageID != tt.wantFirst {
t.Errorf("first = %s, want %s", items[0].MessageID, tt.wantFirst)
}
})
}
}
+64
View File
@@ -0,0 +1,64 @@
package service
import (
"sync"
"time"
)
// loginGuard 按「IP|用户名」双维度做滑动窗口失败计数与锁定;
// 窗口与锁定时长同值、阈值由调用方按安全设置传入(探索文档主题三);
// 内存实现(单体面板),重启清零可接受。
type loginGuard struct {
mu sync.Mutex
failures map[string][]time.Time
lockedAt map[string]time.Time
}
func newLoginGuard() *loginGuard {
return &loginGuard{
failures: map[string][]time.Time{},
lockedAt: map[string]time.Time{},
}
}
// locked 判定 key 是否处于锁定期;过期锁惰性清除。
func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bool {
g.mu.Lock()
defer g.mu.Unlock()
at, ok := g.lockedAt[key]
if !ok {
return false
}
if now.Sub(at) >= lockFor {
delete(g.lockedAt, key)
return false
}
return true
}
// fail 记一次失败并裁剪窗口;达到阈值转入锁定并返回 true(仅触发那一次)。
func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Duration) bool {
g.mu.Lock()
defer g.mu.Unlock()
kept := g.failures[key][:0]
for _, t := range g.failures[key] {
if now.Sub(t) < window {
kept = append(kept, t)
}
}
kept = append(kept, now)
if len(kept) >= limit {
delete(g.failures, key)
g.lockedAt[key] = now
return true
}
g.failures[key] = kept
return false
}
// success 清空 key 的失败计数(成功登录重置窗口)。
func (g *loginGuard) success(key string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.failures, key)
}
+357
View File
@@ -0,0 +1,357 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// ErrRelayNotConfigured 表示 P1 引导创建不可用:面板地址未配置或依赖未注入。
var ErrRelayNotConfigured = errors.New("日志回传引导创建需要配置面板地址(设置 → 安全 → 面板地址,或 PUBLIC_URL 环境变量)")
// 订阅确认轮询节奏:创建订阅后 ONS 向面板投递确认消息,面板回访后订阅转 ACTIVE。
const (
relaySubPollTick = 5 * time.Second
relaySubPollTimeout = 90 * time.Second
relayRollbackWait = 60 * time.Second
)
// RelayCriticalEvents 是回传的关键事件清单(Connector Log Filter 与 P2 告警共用):
// 实例生命周期、用户与凭据、区域订阅、策略变更、控制台登录。
var RelayCriticalEvents = []string{
"LaunchInstance", "TerminateInstance", "InstanceAction",
"CreateUser", "DeleteUser", "UpdateUser",
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities",
"CreateRegionSubscription",
"CreatePolicy", "UpdatePolicy", "DeletePolicy",
"InteractiveLogin",
}
// relayCriticalSet 供 P2 按事件短名 O(1) 判定。
var relayCriticalSet = func() map[string]bool {
m := make(map[string]bool, len(RelayCriticalEvents))
for _, e := range RelayCriticalEvents {
m[e] = true
}
return m
}()
// relayEventClass 把关键事件归入通知子类,对应设置键 notify_event_log_event_<class>;
// 通知管理「云端事件」按此细分开关。
func relayEventClass(name string) string {
switch name {
case "LaunchInstance", "TerminateInstance", "InstanceAction":
return "instance"
case "CreateUser", "DeleteUser", "UpdateUser",
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities":
return "identity"
case "CreatePolicy", "UpdatePolicy", "DeletePolicy":
return "policy"
case "CreateRegionSubscription":
return "region"
case "InteractiveLogin", "FederatedInteractiveLogin":
return "login"
}
return ""
}
// RelayView 是 OCI 侧链路状态视图;Events 为关键事件清单,Ready 表示全链路可用。
type RelayView struct {
Webhook *LogWebhookInfo `json:"webhook,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Topic oci.RelayResource `json:"topic"`
Subscription oci.RelayResource `json:"subscription"`
Connector oci.RelayResource `json:"connector"`
Policy oci.RelayResource `json:"policy"`
Ready bool `json:"ready"`
Events []string `json:"events"`
}
// SetRelayDeps 注入 P1 引导创建依赖;publicURL 为面板公网基址(如 https://demo.example.com)。
func (s *LogEventService) SetRelayDeps(configs *OciConfigService, client oci.Client, publicURL string) {
s.configs = configs
s.relayClient = client
s.publicURL = strings.TrimRight(publicURL, "/")
}
// SetNotifier 注入 P2 告警联动依赖;settings 控制 log_event 事件开关。
func (s *LogEventService) SetNotifier(n *Notifier, settings *SettingService) {
s.notifier = n
s.settings = settings
}
// relayBase 返回生效的公网基址:设置里的 app_url 优先(经 settings),回退启动时的 PUBLIC_URL。
func (s *LogEventService) relayBase() string {
if s.settings != nil {
if u := s.settings.EffectiveAppURL(); u != "" {
return u
}
}
return s.publicURL
}
// relayEndpoint 以公网基址拼接回调完整 URL。
func (s *LogEventService) relayEndpoint(path string) string {
return s.relayBase() + path
}
// relayCreds 加载配置凭据与 home region(IAM 写操作路由用)。
func (s *LogEventService) relayCreds(ctx context.Context, cfgID uint) (oci.Credentials, string, error) {
var cfg model.OciConfig
if err := s.db.WithContext(ctx).First(&cfg, cfgID).Error; err != nil {
return oci.Credentials{}, "", fmt.Errorf("find oci config %d: %w", cfgID, err)
}
cred, err := s.configs.credentialsOf(&cfg)
if err != nil {
return oci.Credentials{}, "", err
}
return cred, cfg.HomeRegionKey, nil
}
// SetupRelay 一键建立回传链路:secret → Topic → 订阅(等待面板自动确认)→ Policy → Connector;
// 任一步失败逆序回滚本次新建的资源。
func (s *LogEventService) SetupRelay(ctx context.Context, cfgID uint) (RelayView, error) {
if s.relayClient == nil || s.relayBase() == "" {
return RelayView{}, ErrRelayNotConfigured
}
info, err := s.EnsureSecret(ctx, cfgID)
if err != nil {
return RelayView{}, err
}
cred, home, err := s.relayCreds(ctx, cfgID)
if err != nil {
return RelayView{}, err
}
if err := s.buildRelay(ctx, cred, home, s.relayEndpoint(info.Path)); err != nil {
return RelayView{}, err
}
return s.RelayStatus(ctx, cfgID)
}
// buildRelay 依序创建链路资源;失败时逆序删除本次新建的部分后返回原错误。
func (s *LogEventService) buildRelay(ctx context.Context, cred oci.Credentials, home, endpoint string) (err error) {
var undo []func(context.Context) error
defer func() {
if err != nil {
s.rollbackRelay(undo)
}
}()
topic, err := s.relayClient.EnsureRelayTopic(ctx, cred)
undo = appendRelayUndo(undo, topic, func(c context.Context) error { return s.relayClient.DeleteRelayTopic(c, cred, topic.ID) })
if err != nil {
return fmt.Errorf("创建 Topic: %w", err)
}
sub, err := s.relayClient.EnsureRelaySubscription(ctx, cred, topic.ID, endpoint)
undo = appendRelayUndo(undo, sub, func(c context.Context) error { return s.relayClient.DeleteRelaySubscription(c, cred, sub.ID) })
if err != nil {
return fmt.Errorf("创建订阅: %w", err)
}
if err = s.waitSubscriptionActive(ctx, cred, sub.ID); err != nil {
return err
}
policy, err := s.relayClient.EnsureRelayPolicy(ctx, cred, home)
undo = appendRelayUndo(undo, policy, func(c context.Context) error { return s.relayClient.DeleteRelayPolicy(c, cred, home, policy.ID) })
if err != nil {
return fmt.Errorf("创建 Policy: %w", err)
}
conn, err := s.relayClient.EnsureRelayConnector(ctx, cred, topic.ID, oci.RelayEventCondition(RelayCriticalEvents))
undo = appendRelayUndo(undo, conn, func(c context.Context) error { return s.relayClient.DeleteRelayConnector(c, cred, conn.ID) })
if err != nil {
return fmt.Errorf("创建 Connector: %w", err)
}
return nil
}
// appendRelayUndo 只为本次新建且拿到 ID 的资源登记回滚动作。
func appendRelayUndo(undo []func(context.Context) error, res oci.RelayResource, del func(context.Context) error) []func(context.Context) error {
if res.Created && res.ID != "" {
return append(undo, del)
}
return undo
}
// rollbackRelay 逆序执行回滚;使用独立超时上下文,原请求取消不影响清理。
func (s *LogEventService) rollbackRelay(undo []func(context.Context) error) {
ctx, cancel := context.WithTimeout(context.Background(), relayRollbackWait)
defer cancel()
for i := len(undo) - 1; i >= 0; i-- {
if err := undo[i](ctx); err != nil {
log.Printf("relay rollback: %v", err)
}
}
}
// waitSubscriptionActive 轮询订阅至 ACTIVE;确认动作由 webhook 端点收到 ONS 消息后自动完成,
// 超时通常意味着面板公网不可达或回调地址配置有误。
func (s *LogEventService) waitSubscriptionActive(ctx context.Context, cred oci.Credentials, subID string) error {
deadline := time.Now().Add(s.subPollTimeout())
for {
res, err := s.relayClient.GetRelaySubscription(ctx, cred, subID)
if err != nil {
return fmt.Errorf("查询订阅状态: %w", err)
}
if res.State == "ACTIVE" {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("订阅确认超时(当前 %s):请确认面板公网可达后重试", res.State)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.subPollTick()):
}
}
}
// subPollTick / subPollTimeout 返回订阅轮询节奏;测试注入短间隔。
func (s *LogEventService) subPollTick() time.Duration {
if s.relayPollTick > 0 {
return s.relayPollTick
}
return relaySubPollTick
}
func (s *LogEventService) subPollTimeout() time.Duration {
if s.relayPollTimeout > 0 {
return s.relayPollTimeout
}
return relaySubPollTimeout
}
// RelayStatus 查询 OCI 侧链路现状与关键事件清单。
func (s *LogEventService) RelayStatus(ctx context.Context, cfgID uint) (RelayView, error) {
if s.relayClient == nil {
return RelayView{}, ErrRelayNotConfigured
}
info, exists, err := s.SecretInfo(ctx, cfgID)
if err != nil {
return RelayView{}, err
}
view := RelayView{Events: RelayCriticalEvents}
endpoint := ""
if exists && s.relayBase() != "" {
endpoint = s.relayEndpoint(info.Path)
view.Webhook, view.Endpoint = &info, endpoint
}
cred, _, err := s.relayCreds(ctx, cfgID)
if err != nil {
return RelayView{}, err
}
st, err := s.relayClient.RelayState(ctx, cred, endpoint)
if err != nil {
return RelayView{}, fmt.Errorf("查询链路状态: %w", err)
}
view.Topic, view.Subscription, view.Connector, view.Policy = st.Topic, st.Subscription, st.Connector, st.Policy
view.Ready = relayReady(exists, st)
return view, nil
}
// relayReady 判定全链路可用:secret 已生成且四资源均处于活跃状态。
func relayReady(secretExists bool, st oci.RelayState) bool {
return secretExists && st.Topic.State == "ACTIVE" && st.Subscription.State == "ACTIVE" &&
st.Connector.State == "ACTIVE" && st.Policy.ID != ""
}
// TeardownRelay 逆序销毁链路(Connector → Policy → 订阅 → Topic)并撤销 secret。
func (s *LogEventService) TeardownRelay(ctx context.Context, cfgID uint) error {
if s.relayClient == nil {
return ErrRelayNotConfigured
}
info, exists, err := s.SecretInfo(ctx, cfgID)
if err != nil {
return err
}
cred, home, err := s.relayCreds(ctx, cfgID)
if err != nil {
return err
}
endpoint := ""
if exists {
endpoint = s.relayEndpoint(info.Path)
}
st, err := s.relayClient.RelayState(ctx, cred, endpoint)
if err != nil {
return fmt.Errorf("查询链路状态: %w", err)
}
if err := s.teardownResources(ctx, cred, home, st); err != nil {
return err
}
return s.RevokeSecret(ctx, cfgID)
}
// teardownResources 按依赖逆序删除存在的链路资源。
func (s *LogEventService) teardownResources(ctx context.Context, cred oci.Credentials, home string, st oci.RelayState) error {
if st.Connector.ID != "" {
if err := s.relayClient.DeleteRelayConnector(ctx, cred, st.Connector.ID); err != nil {
return fmt.Errorf("删除 Connector: %w", err)
}
}
if st.Policy.ID != "" {
if err := s.relayClient.DeleteRelayPolicy(ctx, cred, home, st.Policy.ID); err != nil {
return fmt.Errorf("删除 Policy: %w", err)
}
}
if st.Subscription.ID != "" {
if err := s.relayClient.DeleteRelaySubscription(ctx, cred, st.Subscription.ID); err != nil {
return fmt.Errorf("删除订阅: %w", err)
}
}
if st.Topic.ID != "" {
if err := s.relayClient.DeleteRelayTopic(ctx, cred, st.Topic.ID); err != nil {
return fmt.Errorf("删除 Topic: %w", err)
}
}
return nil
}
// ---- P2 告警联动:解析出关键事件后经 Notifier 推送 ----
// relayEventShortName 取 CloudEvents type 末段(com.oraclecloud.ComputeApi.LaunchInstance → LaunchInstance)。
func relayEventShortName(eventType string) string {
if i := strings.LastIndex(eventType, "."); i >= 0 {
return eventType[i+1:]
}
return eventType
}
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
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
}
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
// MessageID 幂等入库保证同一事件只解析一次,天然防重复推送。
func (s *LogEventService) notifyCritical(ctx context.Context, e *model.LogEvent, p parsedEvent) {
if s.notifier == nil {
return
}
vars, ok := criticalEventVars(s.configAlias(ctx, e.OciConfigID), p)
if !ok {
return
}
class := relayEventClass(relayEventShortName(p.EventType))
if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, "log_event_"+class) {
return
}
s.notifier.SendTemplateAsync("log_event_"+class, vars)
}
// configAlias 查配置别名,失败时退化为编号占位。
func (s *LogEventService) configAlias(ctx context.Context, cfgID uint) string {
var cfg model.OciConfig
if err := s.db.WithContext(ctx).Select("alias").First(&cfg, cfgID).Error; err != nil {
return fmt.Sprintf("租户#%d", cfgID)
}
return cfg.Alias
}
+385
View File
@@ -0,0 +1,385 @@
package service
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// fakeRelayClient 是可编程的回传链路假客户端;内嵌接口,未覆写方法不被调用。
// fail 按步骤名注入错误;calls 记录动作序列供断言编排与回滚顺序。
type fakeRelayClient struct {
oci.Client
mu sync.Mutex
calls []string
fail map[string]error
subStates []string // GetRelaySubscription 依次返回的状态,超出取末位
subIdx int
endpoint string // 最近一次订阅创建收到的 endpoint
connectorTimeout bool // 模拟 Connector 已建但轮询超时
state oci.RelayState
}
func (f *fakeRelayClient) step(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, name)
return f.fail[name]
}
func (f *fakeRelayClient) EnsureRelayTopic(context.Context, oci.Credentials) (oci.RelayResource, error) {
if err := f.step("topic"); err != nil {
return oci.RelayResource{}, err
}
return oci.RelayResource{ID: "t1", State: "ACTIVE", Created: true}, nil
}
func (f *fakeRelayClient) EnsureRelaySubscription(_ context.Context, _ oci.Credentials, topicID, endpoint string) (oci.RelayResource, error) {
f.mu.Lock()
f.endpoint = endpoint
f.mu.Unlock()
if err := f.step("sub"); err != nil {
return oci.RelayResource{}, err
}
return oci.RelayResource{ID: "s1", State: "PENDING", Created: true}, nil
}
func (f *fakeRelayClient) GetRelaySubscription(context.Context, oci.Credentials, string) (oci.RelayResource, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, "getsub")
state := "ACTIVE"
if len(f.subStates) > 0 {
i := f.subIdx
if i >= len(f.subStates) {
i = len(f.subStates) - 1
}
state = f.subStates[i]
f.subIdx++
}
return oci.RelayResource{ID: "s1", State: state}, nil
}
func (f *fakeRelayClient) EnsureRelayPolicy(_ context.Context, _ oci.Credentials, homeRegion string) (oci.RelayResource, error) {
if err := f.step("policy:" + homeRegion); err != nil {
return oci.RelayResource{}, err
}
return oci.RelayResource{ID: "p1", State: "ACTIVE", Created: true}, nil
}
func (f *fakeRelayClient) EnsureRelayConnector(context.Context, oci.Credentials, string, string) (oci.RelayResource, error) {
if err := f.step("connector"); err != nil {
return oci.RelayResource{}, err
}
if f.connectorTimeout {
return oci.RelayResource{ID: "c1", State: "CREATING", Created: true}, errors.New("service connector not active after 90s")
}
return oci.RelayResource{ID: "c1", State: "ACTIVE", Created: true}, nil
}
func (f *fakeRelayClient) RelayState(context.Context, oci.Credentials, string) (oci.RelayState, error) {
if err := f.step("state"); err != nil {
return oci.RelayState{}, err
}
return f.state, nil
}
func (f *fakeRelayClient) DeleteRelayConnector(context.Context, oci.Credentials, string) error {
return f.step("del-connector")
}
func (f *fakeRelayClient) DeleteRelayPolicy(context.Context, oci.Credentials, string, string) error {
return f.step("del-policy")
}
func (f *fakeRelayClient) DeleteRelaySubscription(context.Context, oci.Credentials, string) error {
return f.step("del-sub")
}
func (f *fakeRelayClient) DeleteRelayTopic(context.Context, oci.Credentials, string) error {
return f.step("del-topic")
}
// deletes 过滤出删除动作序列,断言回滚/销毁顺序。
func (f *fakeRelayClient) deletes() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, "del-") {
out = append(out, c)
}
}
return out
}
// newRelayEnv 在 logevent 环境上补齐 relay 依赖:可解密凭据 + 假客户端 + 短轮询。
func newRelayEnv(t *testing.T, fc *fakeRelayClient) (*LogEventService, uint) {
t.Helper()
svc, db, cfgID := newLogEventEnv(t)
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
enc, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
if err != nil {
t.Fatalf("encrypt key: %v", err)
}
var cfg model.OciConfig
if err := db.First(&cfg, cfgID).Error; err != nil {
t.Fatalf("load config: %v", err)
}
cfg.PrivateKeyEnc = enc
cfg.TenancyOCID = "ocid1.tenancy.oc1..t"
cfg.UserOCID = "ocid1.user.oc1..u"
cfg.Region = "us-ashburn-1"
cfg.Fingerprint = "aa:bb"
cfg.HomeRegionKey = "IAD"
if err := db.Save(&cfg).Error; err != nil {
t.Fatalf("seed credentials: %v", err)
}
svc.SetRelayDeps(NewOciConfigService(db, cipher, fc), fc, "https://demo.example.com/")
svc.relayPollTick = time.Millisecond
svc.relayPollTimeout = 30 * time.Millisecond
return svc, cfgID
}
func TestSetupRelayHappyPath(t *testing.T) {
fc := &fakeRelayClient{state: oci.RelayState{
Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"},
Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"},
Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"},
Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"},
}}
svc, cfgID := newRelayEnv(t, fc)
view, err := svc.SetupRelay(context.Background(), cfgID)
if err != nil {
t.Fatalf("SetupRelay: %v", err)
}
if !view.Ready {
t.Errorf("Ready = false, want true; view = %+v", view)
}
if view.Webhook == nil || !strings.HasPrefix(view.Endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") {
t.Errorf("endpoint = %q, want 公网基址拼接", view.Endpoint)
}
if len(view.Events) != len(RelayCriticalEvents) {
t.Errorf("events = %d 项, want %d", len(view.Events), len(RelayCriticalEvents))
}
if dels := fc.deletes(); len(dels) != 0 {
t.Errorf("成功路径不应有删除调用,got %v", dels)
}
// 编排顺序:Topic → 订阅(endpoint 已拼接)→ 确认轮询 → Policy(home region)→ Connector
joined := strings.Join(fc.calls, ",")
for _, frag := range []string{"topic", "sub", "getsub", "policy:IAD", "connector"} {
if !strings.Contains(joined, frag) {
t.Errorf("calls = %s, 缺少 %s", joined, frag)
}
}
if !strings.HasPrefix(fc.endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") {
t.Errorf("订阅 endpoint = %q, want 公网基址拼接", fc.endpoint)
}
}
func TestSetupRelayRollback(t *testing.T) {
tests := []struct {
name string
program func(fc *fakeRelayClient)
wantErr string
wantDelSeq []string
}{
{
name: "订阅创建失败回滚 Topic",
program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"sub": nil} },
wantErr: "创建订阅",
wantDelSeq: []string{"del-topic"},
},
{
name: "Policy 失败回滚订阅与 Topic",
program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"policy:IAD": errors.New("boom")} },
wantErr: "创建 Policy",
wantDelSeq: []string{"del-sub", "del-topic"},
},
{
name: "Connector 创建失败回滚全部前置",
program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"connector": errors.New("boom")} },
wantErr: "创建 Connector",
wantDelSeq: []string{"del-policy", "del-sub", "del-topic"},
},
{
name: "Connector 超时时连自身一并回滚",
program: func(fc *fakeRelayClient) { fc.connectorTimeout = true },
wantErr: "创建 Connector",
wantDelSeq: []string{"del-connector", "del-policy", "del-sub", "del-topic"},
},
{
name: "订阅确认超时回滚订阅与 Topic",
program: func(fc *fakeRelayClient) { fc.subStates = []string{"PENDING"} },
wantErr: "订阅确认超时",
wantDelSeq: []string{"del-sub", "del-topic"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fc := &fakeRelayClient{}
tt.program(fc)
// map 值为 nil 的注入替换为真实错误(subtest 里统一)
for k, v := range fc.fail {
if v == nil {
fc.fail[k] = errors.New("boom")
}
}
svc, cfgID := newRelayEnv(t, fc)
_, err := svc.SetupRelay(context.Background(), cfgID)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want 含 %q", err, tt.wantErr)
}
dels := fc.deletes()
if strings.Join(dels, ",") != strings.Join(tt.wantDelSeq, ",") {
t.Errorf("回滚序列 = %v, want %v", dels, tt.wantDelSeq)
}
})
}
}
func TestSetupRelayRequiresPublicURL(t *testing.T) {
svc, _, cfgID := newLogEventEnv(t)
if _, err := svc.SetupRelay(context.Background(), cfgID); !errors.Is(err, ErrRelayNotConfigured) {
t.Errorf("err = %v, want ErrRelayNotConfigured", err)
}
}
func TestTeardownRelay(t *testing.T) {
fc := &fakeRelayClient{state: oci.RelayState{
Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"},
Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"},
Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"},
Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"},
}}
svc, cfgID := newRelayEnv(t, fc)
ctx := context.Background()
if _, err := svc.EnsureSecret(ctx, cfgID); err != nil {
t.Fatalf("ensure secret: %v", err)
}
if err := svc.TeardownRelay(ctx, cfgID); err != nil {
t.Fatalf("TeardownRelay: %v", err)
}
want := []string{"del-connector", "del-policy", "del-sub", "del-topic"}
if dels := fc.deletes(); strings.Join(dels, ",") != strings.Join(want, ",") {
t.Errorf("销毁序列 = %v, want %v", dels, want)
}
if _, exists, err := svc.SecretInfo(ctx, cfgID); err != nil || exists {
t.Errorf("teardown 后 secret 仍存在 (exists=%v, err=%v)", exists, err)
}
}
func TestRelayReady(t *testing.T) {
active := func() oci.RelayState {
return oci.RelayState{
Topic: oci.RelayResource{ID: "t", State: "ACTIVE"},
Subscription: oci.RelayResource{ID: "s", State: "ACTIVE"},
Connector: oci.RelayResource{ID: "c", State: "ACTIVE"},
Policy: oci.RelayResource{ID: "p", State: "ACTIVE"},
}
}
tests := []struct {
name string
secret bool
mutate func(*oci.RelayState)
want bool
}{
{name: "全链路活跃", secret: true, mutate: func(*oci.RelayState) {}, want: true},
{name: "secret 未生成", secret: false, mutate: func(*oci.RelayState) {}, want: false},
{name: "订阅 PENDING", secret: true, mutate: func(s *oci.RelayState) { s.Subscription.State = "PENDING" }, want: false},
{name: "Connector 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Connector = oci.RelayResource{} }, want: false},
{name: "Policy 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Policy = oci.RelayResource{} }, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := active()
tt.mutate(&st)
if got := relayReady(tt.secret, st); got != tt.want {
t.Errorf("relayReady = %v, want %v", got, tt.want)
}
})
}
}
func TestCriticalEventText(t *testing.T) {
tests := []struct {
name string
event parsedEvent
wantOK bool
wantText 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.IdentityControlPlane.CreateApiKey"},
wantOK: true,
wantText: "☁️ 云端事件:免费01 CreateApiKey",
},
{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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vars, ok := criticalEventVars("免费01", tt.event)
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)
}
}
})
}
}
func TestRelayEventClass(t *testing.T) {
cases := []struct {
name string
class string
}{
{"LaunchInstance", "instance"},
{"TerminateInstance", "instance"},
{"InstanceAction", "instance"},
{"CreateUser", "identity"},
{"DeleteApiKey", "identity"},
{"UpdateUserCapabilities", "identity"},
{"CreatePolicy", "policy"},
{"DeletePolicy", "policy"},
{"CreateRegionSubscription", "region"},
{"InteractiveLogin", "login"},
{"FederatedInteractiveLogin", "login"},
{"ListInstances", ""},
}
for _, tc := range cases {
if got := relayEventClass(tc.name); got != tc.class {
t.Errorf("relayEventClass(%s) = %q, want %q", tc.name, got, tc.class)
}
}
// 关键事件清单里的每个事件都必须有归属子类,防新增事件漏配开关
for _, e := range RelayCriticalEvents {
if relayEventClass(e) == "" {
t.Errorf("critical event %s has no notify class", e)
}
}
}
+196
View File
@@ -0,0 +1,196 @@
package service
import (
"context"
"fmt"
"oci-portal/internal/oci"
)
// Shapes 列出租户在目标区域可用的实例规格。
func (s *OciConfigService) Shapes(ctx context.Context, id uint, region, availabilityDomain string) ([]oci.ComputeShape, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListShapes(ctx, cred, region, availabilityDomain)
}
// Images 列出可用的实例镜像。
func (s *OciConfigService) Images(ctx context.Context, id uint, q oci.ImagesQuery) ([]oci.Image, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListImages(ctx, cred, q)
}
// Image 查询单个镜像。
func (s *OciConfigService) Image(ctx context.Context, id uint, region, imageID string) (oci.Image, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Image{}, err
}
return s.client.GetImage(ctx, cred, region, imageID)
}
// VCNs 列出 VCNcompartmentID 为空表示租户根。
func (s *OciConfigService) VCNs(ctx context.Context, id uint, region, compartmentID string) ([]oci.VCN, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListVCNs(ctx, cred, region)
}
// CreateVCN 创建 VCNin.CompartmentID 为空时建在租户根。
func (s *OciConfigService) CreateVCN(ctx context.Context, id uint, in oci.CreateVCNInput) (oci.VCN, error) {
if in.DisplayName == "" || in.CidrBlock == "" {
return oci.VCN{}, fmt.Errorf("create vcn: displayName and cidrBlock are required")
}
cred, err := s.credentialsInCompartment(ctx, id, in.CompartmentID)
if err != nil {
return oci.VCN{}, err
}
return s.client.CreateVCN(ctx, cred, in)
}
// VCN 查询单个 VCN。
func (s *OciConfigService) VCN(ctx context.Context, id uint, region, vcnID string) (oci.VCN, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.VCN{}, err
}
return s.client.GetVCN(ctx, cred, region, vcnID)
}
// UpdateVCN 重命名 VCN。
func (s *OciConfigService) UpdateVCN(ctx context.Context, id uint, region, vcnID, displayName string) (oci.VCN, error) {
if displayName == "" {
return oci.VCN{}, fmt.Errorf("update vcn: displayName is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.VCN{}, err
}
return s.client.UpdateVCN(ctx, cred, region, vcnID, displayName)
}
// DeleteVCN 删除 VCN。
func (s *OciConfigService) DeleteVCN(ctx context.Context, id uint, region, vcnID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteVCN(ctx, cred, region, vcnID)
}
// EnableVCNIPv6 为 VCN 一键启用 IPv6,返回逐步执行报告。
func (s *OciConfigService) EnableVCNIPv6(ctx context.Context, id uint, region, vcnID string) ([]oci.IPv6Step, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.EnableVCNIPv6(ctx, cred, region, vcnID)
}
// Subnets 列出子网;vcnID 非空时按其所在 compartment 查询,
// 否则 compartmentID 为空表示租户根。
func (s *OciConfigService) Subnets(ctx context.Context, id uint, region, vcnID, compartmentID string) ([]oci.Subnet, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListSubnets(ctx, cred, region, vcnID)
}
// CreateSubnet 创建子网。
func (s *OciConfigService) CreateSubnet(ctx context.Context, id uint, in oci.CreateSubnetInput) (oci.Subnet, error) {
if in.VcnID == "" || in.DisplayName == "" || in.CidrBlock == "" {
return oci.Subnet{}, fmt.Errorf("create subnet: vcnId, displayName and cidrBlock are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Subnet{}, err
}
return s.client.CreateSubnet(ctx, cred, in)
}
// Subnet 查询单个子网。
func (s *OciConfigService) Subnet(ctx context.Context, id uint, region, subnetID string) (oci.Subnet, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Subnet{}, err
}
return s.client.GetSubnet(ctx, cred, region, subnetID)
}
// UpdateSubnet 重命名子网。
func (s *OciConfigService) UpdateSubnet(ctx context.Context, id uint, region, subnetID, displayName string) (oci.Subnet, error) {
if displayName == "" {
return oci.Subnet{}, fmt.Errorf("update subnet: displayName is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Subnet{}, err
}
return s.client.UpdateSubnet(ctx, cred, region, subnetID, displayName)
}
// DeleteSubnet 删除子网。
func (s *OciConfigService) DeleteSubnet(ctx context.Context, id uint, region, subnetID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteSubnet(ctx, cred, region, subnetID)
}
// SecurityLists 列出安全列表;vcnID 非空时按其所在 compartment 查询,
// 否则 compartmentID 为空表示租户根。
func (s *OciConfigService) SecurityLists(ctx context.Context, id uint, region, vcnID, compartmentID string) ([]oci.SecurityList, error) {
cred, err := s.credentialsInCompartment(ctx, id, compartmentID)
if err != nil {
return nil, err
}
return s.client.ListSecurityLists(ctx, cred, region, vcnID)
}
// CreateSecurityList 创建安全列表。
func (s *OciConfigService) CreateSecurityList(ctx context.Context, id uint, in oci.CreateSecurityListInput) (oci.SecurityList, error) {
if in.VcnID == "" || in.DisplayName == "" {
return oci.SecurityList{}, fmt.Errorf("create security list: vcnId and displayName are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.SecurityList{}, err
}
return s.client.CreateSecurityList(ctx, cred, in)
}
// SecurityList 查询单个安全列表。
func (s *OciConfigService) SecurityList(ctx context.Context, id uint, region, securityListID string) (oci.SecurityList, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.SecurityList{}, err
}
return s.client.GetSecurityList(ctx, cred, region, securityListID)
}
// UpdateSecurityList 更新安全列表;规则字段一经提供即整体替换。
func (s *OciConfigService) UpdateSecurityList(ctx context.Context, id uint, securityListID string, in oci.UpdateSecurityListInput) (oci.SecurityList, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.SecurityList{}, err
}
return s.client.UpdateSecurityList(ctx, cred, securityListID, in)
}
// DeleteSecurityList 删除安全列表。
func (s *OciConfigService) DeleteSecurityList(ctx context.Context, id uint, region, securityListID string) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteSecurityList(ctx, cred, region, securityListID)
}
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// notifySendTimeout 是单条通知发送的超时时间。
const notifySendTimeout = 10 * time.Second
// notifyTextLimit 是通知正文长度上限(Telegram 上限 4096 字符,预留转义余量)。
const notifyTextLimit = 3800
// telegramAPIBase 是 Telegram Bot API 官方根地址。
const telegramAPIBase = "https://api.telegram.org"
// Notifier 通过 Telegram Bot API 推送通知;未启用或未配置时静默跳过。
// 云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // API 根地址,测试时注入 httptest 服务
wg sync.WaitGroup
}
// NewNotifier 组装依赖,指向官方 API 地址。
func NewNotifier(settings *SettingService) *Notifier {
return &Notifier{
settings: settings,
client: &http.Client{Timeout: notifySendTimeout},
base: telegramAPIBase,
}
}
// Send 读取配置并发送一条消息;未启用或未配置时直接返回 nil。
func (n *Notifier) Send(ctx context.Context, text string) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
return n.post(ctx, cfg, text)
}
// SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。
func (n *Notifier) SendAsync(text string) {
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.Send(ctx, text); err != nil {
log.Printf("telegram notify: %v", err)
}
}()
}
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
// 模板支持轻量 Markdown,按 Telegram HTML 语义投递。
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.sendTemplate(ctx, kind, "", vars); err != nil {
log.Printf("telegram notify %s: %v", kind, err)
}
}()
}
// SendTemplateTest 用 kind 的示例变量同步发送一条测试消息;
// tplOverride 非空时按给定模板渲染(供编辑表单预览未保存内容)。
func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride string) error {
def, ok := notifyTplDefs[kind]
if !ok {
return fmt.Errorf("未知通知类型 %q", kind)
}
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
}
vars := map[string]string{}
for k, v := range def.Sample {
vars[k] = v
}
return n.sendTemplate(ctx, kind, tplOverride, vars)
}
// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML。
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
tpl := tplOverride
if strings.TrimSpace(tpl) == "" {
custom, err := n.settings.NotifyTemplate(ctx, kind)
if err != nil {
log.Printf("notify template %s: %v", kind, err)
}
tpl = notifyTplText(custom, kind)
}
text := renderNotifyTemplate(tpl, vars)
return n.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit)))
}
// Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。
func (n *Notifier) Wait() {
n.wg.Wait()
}
// Test 同步发送一条测试消息,配置无效时透出可读错误。
func (n *Notifier) Test(ctx context.Context) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("telegram test: bot token and chat id are required")
}
return n.post(ctx, cfg, "✅ oci-portal 测试消息:Telegram 通知配置成功")
}
// post 调用 sendMessage;先截断再做 HTML 转义,避免把转义实体截成两半。
func (n *Notifier) post(ctx context.Context, cfg TelegramConfig, text string) error {
return n.postHTML(ctx, cfg, html.EscapeString(truncateText(text, notifyTextLimit)))
}
// postHTML 调用 sendMessage 发送已按 Telegram HTML 组装的文本(调用方保证转义)。
func (n *Notifier) postHTML(ctx context.Context, cfg TelegramConfig, htmlText string) error {
body, err := json.Marshal(map[string]any{
"chat_id": cfg.ChatID,
"text": htmlText,
"parse_mode": "HTML",
"disable_web_page_preview": true,
})
if err != nil {
return fmt.Errorf("telegram send: %w", err)
}
apiURL := fmt.Sprintf("%s/bot%s/sendMessage", n.base, cfg.BotToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("telegram send: %w", sanitizeURLError(err))
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("telegram send: %w", sanitizeURLError(err))
}
defer resp.Body.Close()
return parseTelegramResponse(resp)
}
// telegramResponse 是 Telegram Bot API 的通用响应包。
type telegramResponse struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
// parseTelegramResponse 解析响应;ok=false 时透出 Telegram 的错误描述。
func parseTelegramResponse(resp *http.Response) error {
var r telegramResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return fmt.Errorf("telegram response (%d): %w", resp.StatusCode, err)
}
if !r.OK {
return fmt.Errorf("telegram: %s", r.Description)
}
return nil
}
// sanitizeURLError 剥掉 *url.Error 外壳只保留底层原因:
// 其 Error() 会拼出完整请求 URL,而路径中含 bot token
// 原样向上返回会把 token 泄漏进日志与接口响应。
func sanitizeURLError(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) {
return uerr.Err
}
return err
}
// truncateText 按 rune 截断超长文本,追加省略号提示。
func truncateText(text string, limit int) string {
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "…"
}
+292
View File
@@ -0,0 +1,292 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"oci-portal/internal/model"
)
// telegramCapture 记录假 Telegram 服务收到的 sendMessage 请求。
type telegramCapture struct {
mu sync.Mutex
paths []string
texts []string
}
// snapshot 返回目前收到的全部文本副本。
func (r *telegramCapture) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.texts...)
}
// newFakeTelegram 起一个假 Telegram API,固定返回 reply 响应体。
func newFakeTelegram(t *testing.T, reply string) (*httptest.Server, *telegramCapture) {
t.Helper()
rec := &telegramCapture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload struct {
Text string `json:"text"`
}
_ = json.NewDecoder(r.Body).Decode(&payload)
rec.mu.Lock()
rec.paths = append(rec.paths, r.URL.Path)
rec.texts = append(rec.texts, payload.Text)
rec.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(reply))
}))
t.Cleanup(srv.Close)
return srv, rec
}
// newTestNotifier 组装指向假 Telegram 服务的 Notifierin 为 nil 表示不预置配置。
func newTestNotifier(t *testing.T, base string, in *UpdateTelegramInput) *Notifier {
t.Helper()
settings, _ := newSettingEnv(t)
if in != nil {
if err := settings.UpdateTelegram(context.Background(), *in); err != nil {
t.Fatalf("update telegram: %v", err)
}
}
n := NewNotifier(settings)
n.base = base
return n
}
func TestNotifierSend(t *testing.T) {
token := "123456:AAfake"
tests := []struct {
name string
setup *UpdateTelegramInput
reply string
wantSent bool
wantErr string // 空串表示期望无错误
}{
{
name: "启用且配置完整时发送",
setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"},
reply: `{"ok":true}`,
wantSent: true,
},
{
name: "未启用时跳过",
setup: &UpdateTelegramInput{Enabled: false, BotToken: &token, ChatID: "42"},
reply: `{"ok":true}`,
},
{
name: "未配置 token 时跳过",
setup: &UpdateTelegramInput{Enabled: true, ChatID: "42"},
reply: `{"ok":true}`,
},
{
name: "完全未配置时跳过",
reply: `{"ok":true}`,
},
{
name: "telegram 报错时透出 description",
setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"},
reply: `{"ok":false,"error_code":401,"description":"Unauthorized"}`,
wantSent: true,
wantErr: "Unauthorized",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, rec := newFakeTelegram(t, tt.reply)
n := newTestNotifier(t, srv.URL, tt.setup)
err := n.Send(context.Background(), "hi <b>&")
if tt.wantErr == "" && err != nil {
t.Fatalf("Send: %v, want nil", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("Send err = %v, want contains %q", err, tt.wantErr)
}
texts := rec.snapshot()
if sent := len(texts) > 0; sent != tt.wantSent {
t.Fatalf("sent = %v (texts %v), want %v", sent, texts, tt.wantSent)
}
if tt.wantSent {
if got, want := rec.paths[0], "/bot"+token+"/sendMessage"; got != want {
t.Errorf("path = %q, want %q", got, want)
}
if got, want := texts[0], "hi &lt;b&gt;&amp;"; got != want {
t.Errorf("text = %q, want HTML 转义后 %q", got, want)
}
}
})
}
}
func TestNotifierTest(t *testing.T) {
token := "123456:AAfake"
tests := []struct {
name string
setup *UpdateTelegramInput
reply string
wantErr string
}{
{
name: "配置有效时发送成功",
setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"},
reply: `{"ok":true}`,
},
{
name: "未配置时报可读错误",
wantErr: "bot token and chat id are required",
},
{
name: "chat 不存在时透出描述",
setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"},
reply: `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`,
wantErr: "chat not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, _ := newFakeTelegram(t, tt.reply)
n := newTestNotifier(t, srv.URL, tt.setup)
err := n.Test(context.Background())
if tt.wantErr == "" && err != nil {
t.Fatalf("Test: %v, want nil", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("Test err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
// TestNotifierSendErrorHidesToken 验证网络层失败的错误信息不含 bot token:
// *url.Error 会拼出完整请求 URL(路径含 token),必须剥壳后再向上返回。
func TestNotifierSendErrorHidesToken(t *testing.T) {
token := "123456:AAfake"
srv, _ := newFakeTelegram(t, `{"ok":true}`)
base := srv.URL
srv.Close() // 立刻关闭:请求必然连接失败
n := newTestNotifier(t, base, &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"})
err := n.Send(context.Background(), "hi")
if err == nil {
t.Fatal("Send = nil, want connection error")
}
if strings.Contains(err.Error(), token) {
t.Errorf("err = %v, 错误信息泄漏了 bot token", err)
}
}
func TestNotifyEvents(t *testing.T) {
tests := []struct {
name string
prev taskSnapshot
cur taskSnapshot
want []notifyEvent // Kind 精确匹配,Vars 为需包含的变量键值;空表示不发
}{
{
name: "成功转失败发失败通知",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"},
want: []notifyEvent{{Kind: notifyTaskFail, Vars: map[string]string{"task_name": "抢机", "error": "Out of host capacity"}}},
},
{
name: "连续失败不重复发",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"},
want: nil,
},
{
name: "失败换了错误内容也不重复发",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "capacity"},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "timeout"},
want: nil,
},
{
name: "失败恢复成功发恢复通知",
prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive, LastError: "boom"},
cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive},
want: []notifyEvent{{Kind: notifyTaskRecover, Vars: map[string]string{"task_name": "测活"}}},
},
{
name: "持续正常不发",
prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive},
cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive},
want: nil,
},
{
name: "非成功转成功发抢机成功",
prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive},
cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"},
want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}},
},
{
name: "失败后直接抢满只发抢机成功不叠加恢复",
prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive, LastError: "capacity"},
cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"},
want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := notifyEvents(tt.prev, tt.cur)
if len(got) != len(tt.want) {
t.Fatalf("events = %v (%d 条), want %d 条", got, len(got), len(tt.want))
}
for i, w := range tt.want {
if got[i].Kind != w.Kind {
t.Errorf("event[%d].Kind = %q, want %q", i, got[i].Kind, w.Kind)
}
for k, v := range w.Vars {
if got[i].Vars[k] != v {
t.Errorf("event[%d].Vars[%s] = %q, want %q", i, k, got[i].Vars[k], v)
}
}
}
})
}
}
func TestSameStringSet(t *testing.T) {
tests := []struct {
name string
a, b []string
want bool
}{
{name: "都为空", a: nil, b: nil, want: true},
{name: "顺序不同视为相同", a: []string{"x", "y"}, b: []string{"y", "x"}, want: true},
{name: "长度不同", a: []string{"x"}, b: []string{"x", "y"}, want: false},
{name: "元素不同", a: []string{"x"}, b: []string{"y"}, want: false},
{name: "重复元素次数不同", a: []string{"x", "y"}, b: []string{"x", "x"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sameStringSet(tt.a, tt.b); got != tt.want {
t.Errorf("sameStringSet(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestTruncateText(t *testing.T) {
tests := []struct {
name string
text string
limit int
want string
}{
{name: "未超限原样返回", text: "hello", limit: 10, want: "hello"},
{name: "超限截断加省略号", text: "hello", limit: 3, want: "hel…"},
{name: "多字节按 rune 截断", text: "抢机成功", limit: 2, want: "抢机…"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := truncateText(tt.text, tt.limit); got != tt.want {
t.Errorf("truncateText(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want)
}
})
}
}
+163
View File
@@ -0,0 +1,163 @@
package service
import (
"html"
"regexp"
"strings"
"time"
)
// notifyTplDef 描述一类通知的模板元数据与示例变量(测试发送用)。
// 所有模板额外提供公共变量 {{time}}(发送时刻)。
type notifyTplDef struct {
Label string
Default string
Vars []string
Sample map[string]string
}
// notifyTplOrder 是模板列表的稳定输出顺序(与通知管理事件一致)。
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",
}
var notifyTplDefs = map[string]notifyTplDef{
"task_fail": {
Label: "任务失败",
Default: "❌ 任务失败:{{task_name}}\n{{error}}",
Vars: []string{"task_name", "error"},
Sample: map[string]string{"task_name": "示例测活任务", "error": "鉴权失败: 401 NotAuthenticated"},
},
"task_recover": {
Label: "任务恢复",
Default: "✅ 任务恢复:{{task_name}}",
Vars: []string{"task_name"},
Sample: map[string]string{"task_name": "示例测活任务"},
},
"snatch_success": {
Label: "抢机成功",
Default: "🎉 抢机成功:{{task_name}}\n{{message}}",
Vars: []string{"task_name", "message"},
Sample: map[string]string{"task_name": "示例抢机任务", "message": "已创建 1/1 台实例"},
},
"tenant_dead": {
Label: "租户失联",
Default: "⚠️ 租户失联:{{tenants}}",
Vars: []string{"tenants"},
Sample: map[string]string{"tenants": "免费01、免费02"},
},
"task_stop": {
Label: "任务停止",
Default: "⛔ 任务已停止:{{task_name}}\n{{error}}",
Vars: []string{"task_name", "error"},
Sample: map[string]string{"task_name": "示例抢机任务", "error": "连续鉴权失败 5 次,任务熔断"},
},
"login_lock": {
Label: "登录锁定",
Default: "🚫 登录锁定:用户 {{username}}(来自 {{ip}})连续失败 {{fail_count}} 次,已锁定 {{lock_minutes}} 分钟",
Vars: []string{"username", "ip", "fail_count", "lock_minutes"},
Sample: map[string]string{"username": "admin", "ip": "203.0.113.8", "fail_count": "5", "lock_minutes": "15"},
},
"model_deprecated": {
Label: "模型弃用预警",
Default: "⚠️ AI 网关:以下在池模型即将被 OCI 退役或弃用(退役后无法调用,弃用仅为预告)\n{{models}}",
Vars: []string{"models"},
Sample: map[string]string{"models": "meta.llama-3.1-70b-instruct(2026-08-01 退役,届时无法调用)"},
},
"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"},
},
"log_event_identity": {
Label: "用户与凭据",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo"},
},
"log_event_policy": {
Label: "策略变更",
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "UpdatePolicy", "resource": "admin-policy"},
},
"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"},
},
"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"},
},
}
var notifyVarPattern = regexp.MustCompile(`\{\{(\w+)\}\}`)
// renderNotifyTemplate 用 {{var}} 占位符渲染模板;未知变量原样保留,
// 公共变量 time 自动注入(调用方未提供时)。
func renderNotifyTemplate(tpl string, vars map[string]string) string {
if _, ok := vars["time"]; !ok {
if vars == nil {
vars = map[string]string{}
}
vars["time"] = time.Now().Format("2006-01-02 15:04:05")
}
return notifyVarPattern.ReplaceAllStringFunc(tpl, func(m string) string {
if v, ok := vars[m[2:len(m)-2]]; ok {
return v
}
return m
})
}
var (
notifyMdPre = regexp.MustCompile("(?s)```\n?(.+?)\n?```")
notifyMdCode = regexp.MustCompile("`([^`\n]+)`")
notifyMdBold = regexp.MustCompile(`\*([^*\n]+)\*`)
notifyMdItalic = regexp.MustCompile(`_([^_\n]+)_`)
)
// mdToTelegramHTML 把轻量 Markdown(*粗体* / _斜体_ / `代码` / ```代码块```)转为
// Telegram HTML;先整体 HTML 转义,未闭合标记原样保留,天然不产生残缺标签。
func mdToTelegramHTML(text string) string {
out := html.EscapeString(text)
out = notifyMdPre.ReplaceAllString(out, "<pre>$1</pre>")
out = notifyMdCode.ReplaceAllString(out, "<code>$1</code>")
out = notifyMdBold.ReplaceAllString(out, "<b>$1</b>")
out = notifyMdItalic.ReplaceAllString(out, "<i>$1</i>")
return out
}
// NotifyTemplateView 是模板设置接口的列表项。
type NotifyTemplateView struct {
Kind string `json:"kind"`
Label string `json:"label"`
Vars []string `json:"vars"`
DefaultTemplate string `json:"defaultTemplate"`
// Template 是自定义模板;空串表示未自定义(按默认发送)
Template string `json:"template"`
}
// notifyTplKey 是 kind 对应的 Setting 键。
func notifyTplKey(kind string) string { return "notify_tpl_" + kind }
// notifyTplValid 报告 kind 是否为已知通知类型。
func notifyTplValid(kind string) bool {
_, ok := notifyTplDefs[kind]
return ok
}
// notifyTplText 返回 kind 生效模板:自定义非空用自定义,否则默认。
func notifyTplText(custom, kind string) string {
if strings.TrimSpace(custom) != "" {
return custom
}
return notifyTplDefs[kind].Default
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"context"
"strings"
"testing"
)
func TestRenderNotifyTemplate(t *testing.T) {
out := renderNotifyTemplate("❌ {{task_name}}\n{{error}}\n{{unknown}}", map[string]string{
"task_name": "抢机", "error": "boom",
})
if !strings.Contains(out, "抢机") || !strings.Contains(out, "boom") {
t.Errorf("渲染缺变量: %q", out)
}
if !strings.Contains(out, "{{unknown}}") {
t.Errorf("未知变量应原样保留: %q", out)
}
// 公共变量 time 自动注入
out = renderNotifyTemplate("at {{time}}", nil)
if strings.Contains(out, "{{time}}") {
t.Errorf("time 未注入: %q", out)
}
}
func TestMdToTelegramHTML(t *testing.T) {
got := mdToTelegramHTML("*任务失败*:`err<1>` _注意_\n```\nraw & code\n```")
for _, want := range []string{"<b>任务失败</b>", "<code>err&lt;1&gt;</code>", "<i>注意</i>", "<pre>raw &amp; code</pre>"} {
if !strings.Contains(got, want) {
t.Errorf("got %q, want contains %q", got, want)
}
}
// 未闭合标记原样保留,不产生残缺标签
if got := mdToTelegramHTML("孤立*星号 和 `反引号"); strings.Contains(got, "<b>") || strings.Contains(got, "<code>") {
t.Errorf("未闭合标记不应转换: %q", got)
}
}
func TestNotifyTemplateReadWrite(t *testing.T) {
svc, _ := newSettingEnv(t)
ctx := context.Background()
items, err := svc.NotifyTemplates(ctx)
if err != nil || len(items) != len(notifyTplOrder) {
t.Fatalf("NotifyTemplates = %d 项, %v", len(items), err)
}
if items[0].Kind != "task_fail" || items[0].DefaultTemplate == "" || items[0].Template != "" {
t.Errorf("默认视图 = %+v", items[0])
}
// 保存自定义 → 读回
if err := svc.UpdateNotifyTemplate(ctx, "task_fail", "自定义 {{task_name}}"); err != nil {
t.Fatalf("UpdateNotifyTemplate: %v", err)
}
custom, _ := svc.NotifyTemplate(ctx, "task_fail")
if custom != "自定义 {{task_name}}" {
t.Errorf("读回 = %q", custom)
}
if notifyTplText(custom, "task_fail") != "自定义 {{task_name}}" {
t.Error("生效模板应为自定义")
}
// 清空恢复默认
if err := svc.UpdateNotifyTemplate(ctx, "task_fail", ""); err != nil {
t.Fatalf("清空: %v", err)
}
custom, _ = svc.NotifyTemplate(ctx, "task_fail")
if notifyTplText(custom, "task_fail") != notifyTplDefs["task_fail"].Default {
t.Error("清空后应回落默认模板")
}
// 未知 kind / 超长拒绝
if err := svc.UpdateNotifyTemplate(ctx, "nope", "x"); err == nil {
t.Error("未知 kind 应被拒绝")
}
if err := svc.UpdateNotifyTemplate(ctx, "task_fail", strings.Repeat("字", 2001)); err == nil {
t.Error("超长模板应被拒绝")
}
}
+349
View File
@@ -0,0 +1,349 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
githubep "golang.org/x/oauth2/github"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// OAuth 流程错误;api 层映射为用户可读的回跳提示。
var (
// ErrOAuthNotConfigured 表示 provider 未配置(clientID 缺失)。
ErrOAuthNotConfigured = errors.New("该登录方式未配置")
// ErrOAuthNoAppURL 表示面板地址缺失,回调 URL 无从拼接。
ErrOAuthNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址")
// ErrOAuthDisabled 表示 provider 已被禁用,登录入口不可用(绑定不受影响)。
ErrOAuthDisabled = errors.New("该登录方式已禁用")
// ErrOAuthState 表示 state 无效或已过期(CSRF 防护)。
ErrOAuthState = errors.New("授权状态无效或已过期,请重新发起")
// ErrOAuthNotBound 表示外部身份未绑定任何账号,拒绝登录。
ErrOAuthNotBound = errors.New("该外部身份未绑定面板账号,请先登录后在设置中绑定")
// ErrOAuthBound 表示身份已被绑定(重复绑定)。
ErrOAuthBound = errors.New("该外部身份已绑定过")
)
// oauthPendingTTL 是授权流程 state 的有效期。
const oauthPendingTTL = 10 * time.Minute
// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。
type oauthPending struct {
provider string
mode string // "login" / "bind"
username string // bind 模式的绑定目标账号
nonce string // OIDC 防 id_token 重放
expires time.Time
}
// OAuthService 承接外部身份登录与绑定(OIDC / GitHub,探索文档主题二)。
type OAuthService struct {
db *gorm.DB
settings *SettingService
auth *AuthService
mu sync.Mutex
pending map[string]oauthPending
}
// NewOAuthService 组装依赖。
func NewOAuthService(db *gorm.DB, settings *SettingService, auth *AuthService) *OAuthService {
return &OAuthService{db: db, settings: settings, auth: auth, pending: map[string]oauthPending{}}
}
// ProviderInfo 是登录页公开的 provider 信息(displayName 空配置时为默认名)。
type ProviderInfo struct {
Provider string `json:"provider"`
DisplayName string `json:"displayName"`
}
// Providers 返回可登录的 provider 列表(clientID 非空且未禁用),登录页据此显示按钮。
func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo {
out := []ProviderInfo{}
for _, p := range []string{"oidc", "github"} {
id, _, _, err := o.settings.oauthClient(ctx, p)
if err != nil || id == "" {
continue
}
display, disabled, err := o.settings.oauthProviderMeta(ctx, p)
if err != nil || disabled {
continue
}
out = append(out, ProviderInfo{Provider: p, DisplayName: display})
}
return out
}
// randHex 生成 n 字节随机数的 hex 编码。
func randHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("random: %w", err)
}
return hex.EncodeToString(buf), nil
}
// callbackURL 是 provider 回调地址,固定拼自面板地址(白名单即此一条)。
func (o *OAuthService) callbackURL(provider string) string {
return o.settings.EffectiveAppURL() + "/api/v1/auth/oauth/" + provider + "/callback"
}
// oauth2Config 构造 provider 的 oauth2 配置;oidc 时一并返回已发现的 provider。
func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oauth2.Config, *oidc.Provider, error) {
clientID, secret, issuer, err := o.settings.oauthClient(ctx, provider)
if err != nil {
return nil, nil, err
}
if clientID == "" {
return nil, nil, ErrOAuthNotConfigured
}
if o.settings.EffectiveAppURL() == "" {
return nil, nil, ErrOAuthNoAppURL
}
cfg := &oauth2.Config{ClientID: clientID, ClientSecret: secret, RedirectURL: o.callbackURL(provider)}
if provider == "github" {
cfg.Endpoint = githubep.Endpoint
cfg.Scopes = []string{"read:user"}
return cfg, nil, nil
}
op, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, nil, fmt.Errorf("oidc discovery: %w", err)
}
cfg.Endpoint = op.Endpoint()
cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"}
return cfg, op, nil
}
// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。
// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。
func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username string) (string, error) {
if mode == "login" {
if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled {
return "", ErrOAuthDisabled
}
}
cfg, _, err := o.oauth2Config(ctx, provider)
if err != nil {
return "", err
}
state, err := randHex(16)
if err != nil {
return "", err
}
nonce, err := randHex(16)
if err != nil {
return "", err
}
o.mu.Lock()
o.gcPendingLocked()
o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, expires: time.Now().Add(oauthPendingTTL)}
o.mu.Unlock()
opts := []oauth2.AuthCodeOption{}
if provider == "oidc" {
opts = append(opts, oidc.Nonce(nonce))
}
return cfg.AuthCodeURL(state, opts...), nil
}
// gcPendingLocked 清理过期流程;调用方须持锁。
func (o *OAuthService) gcPendingLocked() {
now := time.Now()
for k, p := range o.pending {
if now.After(p.expires) {
delete(o.pending, k)
}
}
}
// takeState 取出并消费 state(一次性);provider 不匹配或过期视为无效。
func (o *OAuthService) takeState(provider, state string) (oauthPending, error) {
o.mu.Lock()
defer o.mu.Unlock()
p, ok := o.pending[state]
delete(o.pending, state)
if !ok || p.provider != provider || time.Now().After(p.expires) {
return oauthPending{}, ErrOAuthState
}
return p, nil
}
// externalIdentity 是从 provider 换回的外部身份。
type externalIdentity struct {
Subject string
Display string
}
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) {
p, err := o.takeState(provider, state)
if err != nil {
return "", "", "", err
}
ident, err := o.fetchIdentity(ctx, provider, code, p.nonce)
if err != nil {
return "", "", p.mode, err
}
if p.mode == "bind" {
return "", ident.Display, p.mode, o.bind(ctx, p.username, provider, ident)
}
token, display, err = o.loginByIdentity(ctx, provider, ident)
return token, display, p.mode, err
}
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) {
cfg, op, err := o.oauth2Config(ctx, provider)
if err != nil {
return externalIdentity{}, err
}
tok, err := cfg.Exchange(ctx, code)
if err != nil {
return externalIdentity{}, fmt.Errorf("exchange code: %w", err)
}
if provider == "github" {
return githubIdentity(ctx, cfg, tok)
}
return oidcIdentity(ctx, cfg, op, tok, nonce)
}
// githubIdentity 调 GitHub /user 拿数字 id(login 可改名,不可作 subject)。
func githubIdentity(ctx context.Context, cfg *oauth2.Config, tok *oauth2.Token) (externalIdentity, error) {
client := cfg.Client(ctx, tok)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
if err != nil {
return externalIdentity{}, fmt.Errorf("github user request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return externalIdentity{}, fmt.Errorf("github user: %w", sanitizeURLError(err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return externalIdentity{}, fmt.Errorf("github user: status %d", resp.StatusCode)
}
var u struct {
ID int64 `json:"id"`
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil || u.ID == 0 {
return externalIdentity{}, fmt.Errorf("github user: invalid response")
}
return externalIdentity{Subject: fmt.Sprintf("%d", u.ID), Display: u.Login}, nil
}
// oidcIdentity 验证 id_token 签名 / audience / nonce,取 sub 作 subject。
func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, tok *oauth2.Token, nonce string) (externalIdentity, error) {
raw, ok := tok.Extra("id_token").(string)
if !ok || raw == "" {
return externalIdentity{}, fmt.Errorf("oidc: id_token missing")
}
idToken, err := op.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(ctx, raw)
if err != nil {
return externalIdentity{}, fmt.Errorf("oidc verify: %w", err)
}
if idToken.Nonce != nonce {
return externalIdentity{}, fmt.Errorf("oidc: nonce mismatch")
}
var claims struct {
Email string `json:"email"`
}
_ = idToken.Claims(&claims)
display := claims.Email
if display == "" {
display = idToken.Subject
}
return externalIdentity{Subject: idToken.Subject, Display: display}, nil
}
// bind 把外部身份绑定到账号;(provider, subject) 唯一,重复绑定报错。
func (o *OAuthService) bind(ctx context.Context, username, provider string, ident externalIdentity) error {
user, err := o.auth.findUser(ctx, username)
if err != nil {
return err
}
var count int64
err = o.db.WithContext(ctx).Model(&model.UserIdentity{}).
Where("provider = ? AND subject = ?", provider, ident.Subject).Count(&count).Error
if err != nil {
return fmt.Errorf("check identity: %w", err)
}
if count > 0 {
return ErrOAuthBound
}
row := model.UserIdentity{UserID: user.ID, Provider: provider, Subject: ident.Subject, Display: ident.Display}
if err := o.db.WithContext(ctx).Create(&row).Error; err != nil {
return fmt.Errorf("bind identity: %w", err)
}
return nil
}
// loginByIdentity 查绑定关系并签发面板 JWT;未绑定一律拒绝(不开放注册)。
func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity) (string, string, error) {
var row model.UserIdentity
err := o.db.WithContext(ctx).
Where("provider = ? AND subject = ?", provider, ident.Subject).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", ErrOAuthNotBound
}
return "", "", fmt.Errorf("find identity: %w", err)
}
var user model.User
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
return "", "", fmt.Errorf("find bound user: %w", err)
}
token, _, err := o.auth.signToken(user.Username)
return token, ident.Display, err
}
// Identities 列出账号已绑定的外部身份。
func (o *OAuthService) Identities(ctx context.Context, username string) ([]model.UserIdentity, error) {
user, err := o.auth.findUser(ctx, username)
if err != nil {
return nil, err
}
items := []model.UserIdentity{}
err = o.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&items).Error
if err != nil {
return nil, fmt.Errorf("list identities: %w", err)
}
return items, nil
}
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
user, err := o.auth.findUser(ctx, username)
if err != nil {
return err
}
n, err := o.auth.identityCount(ctx, user.ID)
if err != nil {
return err
}
if n <= 1 {
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
return ErrLastIdentity
}
}
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
if res.Error != nil {
return fmt.Errorf("unbind identity: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package service
import (
"context"
"fmt"
"strings"
)
// OAuth provider 配置键;client secret 以 AES-GCM 密文落库。
const (
settingOauthOidcIssuer = "oauth_oidc_issuer"
settingOauthOidcClientID = "oauth_oidc_client_id"
settingOauthOidcClientSecret = "oauth_oidc_client_secret"
settingOauthOidcDisplayName = "oauth_oidc_display_name"
settingOauthOidcDisabled = "oauth_oidc_disabled"
settingOauthGithubClientID = "oauth_github_client_id"
settingOauthGithubClientSecret = "oauth_github_client_secret"
settingOauthGithubDisplayName = "oauth_github_display_name"
settingOauthGithubDisabled = "oauth_github_disabled"
)
// OAuthProvidersView 是 OAuth provider 配置视图;绝不返回 secret 明文。
type OAuthProvidersView struct {
OidcIssuer string `json:"oidcIssuer"`
OidcClientID string `json:"oidcClientId"`
OidcSecretSet bool `json:"oidcSecretSet"`
OidcDisplayName string `json:"oidcDisplayName"`
OidcDisabled bool `json:"oidcDisabled"`
GithubClientID string `json:"githubClientId"`
GithubSecretSet bool `json:"githubSecretSet"`
GithubDisplayName string `json:"githubDisplayName"`
GithubDisabled bool `json:"githubDisabled"`
}
// UpdateOAuthInput 是保存 provider 配置的输入;
// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。
type UpdateOAuthInput struct {
OidcIssuer string `json:"oidcIssuer"`
OidcClientID string `json:"oidcClientId"`
OidcClientSecret *string `json:"oidcClientSecret"`
OidcDisplayName string `json:"oidcDisplayName"`
OidcDisabled bool `json:"oidcDisabled"`
GithubClientID string `json:"githubClientId"`
GithubClientSecret *string `json:"githubClientSecret"`
GithubDisplayName string `json:"githubDisplayName"`
GithubDisabled bool `json:"githubDisabled"`
}
// OAuthView 返回脱敏后的 provider 配置。
func (s *SettingService) OAuthView(ctx context.Context) (OAuthProvidersView, error) {
var view OAuthProvidersView
vals, err := s.getMany(ctx,
settingOauthOidcIssuer, settingOauthOidcClientID, settingOauthOidcClientSecret,
settingOauthOidcDisplayName, settingOauthOidcDisabled,
settingOauthGithubClientID, settingOauthGithubClientSecret,
settingOauthGithubDisplayName, settingOauthGithubDisabled)
if err != nil {
return view, err
}
view.OidcIssuer = vals[settingOauthOidcIssuer]
view.OidcClientID = vals[settingOauthOidcClientID]
view.OidcSecretSet = vals[settingOauthOidcClientSecret] != ""
view.OidcDisplayName = vals[settingOauthOidcDisplayName]
view.OidcDisabled = vals[settingOauthOidcDisabled] == "1"
view.GithubClientID = vals[settingOauthGithubClientID]
view.GithubSecretSet = vals[settingOauthGithubClientSecret] != ""
view.GithubDisplayName = vals[settingOauthGithubDisplayName]
view.GithubDisabled = vals[settingOauthGithubDisabled] == "1"
return view, nil
}
// boolFlag 把开关序列化为 settings 存储值。
func boolFlag(on bool) string {
if on {
return "1"
}
return ""
}
// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库。
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
plain := map[string]string{
settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"),
settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID),
settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName),
settingOauthOidcDisabled: boolFlag(in.OidcDisabled),
settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID),
settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName),
settingOauthGithubDisabled: boolFlag(in.GithubDisabled),
}
for key, value := range plain {
if err := s.set(ctx, key, value); err != nil {
return err
}
}
if err := s.saveOAuthSecret(ctx, settingOauthOidcClientSecret, in.OidcClientSecret); err != nil {
return err
}
return s.saveOAuthSecret(ctx, settingOauthGithubClientSecret, in.GithubClientSecret)
}
// saveOAuthSecret 加密保存 secret;nil 沿用,空串清除。
func (s *SettingService) saveOAuthSecret(ctx context.Context, key string, secret *string) error {
if secret == nil {
return nil
}
if *secret == "" {
return s.set(ctx, key, "")
}
enc, err := s.cipher.EncryptString(*secret)
if err != nil {
return fmt.Errorf("encrypt oauth secret: %w", err)
}
return s.set(ctx, key, enc)
}
// oauthClient 返回 provider 的 clientID/明文 secret/issuer(仅 oidc);未配置时 clientID 为空。
func (s *SettingService) oauthClient(ctx context.Context, provider string) (clientID, secret, issuer string, err error) {
idKey, secKey := settingOauthGithubClientID, settingOauthGithubClientSecret
if provider == "oidc" {
idKey, secKey = settingOauthOidcClientID, settingOauthOidcClientSecret
if issuer, err = s.get(ctx, settingOauthOidcIssuer); err != nil {
return
}
}
if clientID, err = s.get(ctx, idKey); err != nil {
return
}
enc, err := s.get(ctx, secKey)
if err != nil || enc == "" {
return
}
if secret, err = s.cipher.DecryptString(enc); err != nil {
err = fmt.Errorf("decrypt oauth secret: %w", err)
}
return
}
// oauthProviderMeta 返回 provider 的展示名(空则给默认名)与禁用态。
func (s *SettingService) oauthProviderMeta(ctx context.Context, provider string) (display string, disabled bool, err error) {
nameKey, offKey, def := settingOauthGithubDisplayName, settingOauthGithubDisabled, "GitHub"
if provider == "oidc" {
nameKey, offKey, def = settingOauthOidcDisplayName, settingOauthOidcDisabled, "OIDC 单点登录"
}
if display, err = s.get(ctx, nameKey); err != nil {
return
}
if display == "" {
display = def
}
off, err := s.get(ctx, offKey)
disabled = off == "1"
return
}
+442
View File
@@ -0,0 +1,442 @@
package service
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"oci-portal/internal/cache"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// OciConfigService 管理 API Key 配置的导入、测活与快照同步。
type OciConfigService struct {
db *gorm.DB
cipher *crypto.Cipher
client oci.Client
// auditRaw 暂存审计原始事件(eventId → raw,TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache
}
// NewOciConfigService 组装依赖。
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
}
// ImportInput 是导入一份 API Key 的输入:
// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。
type ImportInput struct {
Alias string
Group string
ConfigINI string
TenancyOCID string
UserOCID string
Region string
Fingerprint string
PrivateKey string
Passphrase string
// 多区域 / 多区间支持:开启后订阅区域与 compartment 入库缓存
MultiRegion bool
MultiCompartment bool
// ProxyID 关联出站代理,nil 直连
ProxyID *uint
}
// Changes 记录一次测活前后云端信息字段的变化,值为 [旧值, 新值]。
type Changes map[string][2]string
// Import 保存一份新的 API Key 配置,随后立即测活并获取账户类别。
// 测活失败不回滚导入,结果记录在快照的状态字段里。
func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.OciConfig, error) {
cred, err := s.buildCredentials(in)
if err != nil {
return nil, err
}
cfg, err := s.storeConfig(in.Alias, cred)
if err != nil {
return nil, err
}
cfg.Group = in.Group
cfg.MultiRegion = in.MultiRegion
cfg.MultiCompartment = in.MultiCompartment
cfg.ProxyID = in.ProxyID
if err := s.refresh(ctx, cfg, cred); err != nil {
return nil, err
}
return cfg, nil
}
// Verify 重新测活并拉取云端信息,返回更新后的快照和字段变更集。
func (s *OciConfigService) Verify(ctx context.Context, id uint) (*model.OciConfig, Changes, error) {
var cfg model.OciConfig
if err := s.db.First(&cfg, id).Error; err != nil {
return nil, nil, fmt.Errorf("find oci config %d: %w", id, err)
}
cred, err := s.credentialsOf(&cfg)
if err != nil {
return nil, nil, err
}
before := cfg
if err := s.refresh(ctx, &cfg, cred); err != nil {
return nil, nil, err
}
return &cfg, diffSnapshots(before, cfg), nil
}
// UpdateInput 是修改配置的输入;空字段保持不变。
// 替换私钥时必须同时提供新指纹;Passphrase 非 nil 时整体覆盖(空串表示清除口令)。
// Group 非 nil 时整体覆盖(空串表示取消分组)。
// MultiRegion / MultiCompartment 非 nil 时切换开关,开启后随本次测活同步缓存。
type UpdateInput struct {
Alias string
Group *string
Region string
Fingerprint string
PrivateKey string
Passphrase *string
MultiRegion *bool
MultiCompartment *bool
// ProxyID 非 nil 时切换关联:0 表示解除代理,>0 表示关联到该代理
ProxyID *uint
}
// Update 修改配置的别名 / 默认区域 / 凭据字段,随后立即重新测活。
func (s *OciConfigService) Update(ctx context.Context, id uint, in UpdateInput) (*model.OciConfig, Changes, error) {
if in.PrivateKey != "" && in.Fingerprint == "" {
return nil, nil, fmt.Errorf("update oci config: fingerprint is required when replacing private key")
}
var cfg model.OciConfig
if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil {
return nil, nil, fmt.Errorf("find oci config %d: %w", id, err)
}
if err := s.applyUpdate(&cfg, in); err != nil {
return nil, nil, err
}
cred, err := s.credentialsOf(&cfg)
if err != nil {
return nil, nil, err
}
before := cfg
if err := s.refresh(ctx, &cfg, cred); err != nil {
return nil, nil, err
}
return &cfg, diffSnapshots(before, cfg), nil
}
// applyUpdate 把非空输入写进快照字段,凭据字段交由 applyCredentialUpdate 加密。
func (s *OciConfigService) applyUpdate(cfg *model.OciConfig, in UpdateInput) error {
if in.Alias != "" {
cfg.Alias = in.Alias
}
if in.Group != nil {
cfg.Group = *in.Group
}
if in.Region != "" {
cfg.Region = in.Region
}
if in.Fingerprint != "" {
cfg.Fingerprint = in.Fingerprint
}
if in.MultiRegion != nil {
cfg.MultiRegion = *in.MultiRegion
}
if in.MultiCompartment != nil {
cfg.MultiCompartment = *in.MultiCompartment
}
if in.ProxyID != nil {
if *in.ProxyID == 0 {
cfg.ProxyID = nil
} else {
cfg.ProxyID = in.ProxyID
}
}
return s.applyCredentialUpdate(cfg, in)
}
// applyCredentialUpdate 重新加密私钥与口令;Passphrase 非 nil 时整体覆盖。
func (s *OciConfigService) applyCredentialUpdate(cfg *model.OciConfig, in UpdateInput) error {
if in.PrivateKey != "" {
enc, err := s.cipher.EncryptString(in.PrivateKey)
if err != nil {
return fmt.Errorf("encrypt private key: %w", err)
}
cfg.PrivateKeyEnc = enc
}
if in.Passphrase == nil {
return nil
}
cfg.PassphraseEnc = ""
if *in.Passphrase != "" {
enc, err := s.cipher.EncryptString(*in.Passphrase)
if err != nil {
return fmt.Errorf("encrypt passphrase: %w", err)
}
cfg.PassphraseEnc = enc
}
return nil
}
// ConfigSummary 是列表接口的瘦身视图:只含列表页与全局作用域选择器
// 消费的字段,订阅 / 促销 / 凭据元数据等详情字段走 Get 单查。
type ConfigSummary struct {
ID uint `json:"id"`
Alias string `json:"alias"`
Group string `json:"group"`
TenancyOCID string `json:"tenancyOcid"`
TenancyName string `json:"tenancyName"`
Region string `json:"region"`
AccountType string `json:"accountType"`
AliveStatus string `json:"aliveStatus"`
LastError string `json:"lastError"`
LastVerifiedAt *time.Time `json:"lastVerifiedAt"`
MultiRegion bool `json:"multiRegion"`
MultiCompartment bool `json:"multiCompartment"`
ProxyID *uint `json:"proxyId"`
// ProxyName 由 List 按关联填充,供表格代理列 hover 展示
ProxyName string `json:"proxyName"`
CreatedAt time.Time `json:"createdAt"`
}
func toConfigSummary(cfg model.OciConfig) ConfigSummary {
return ConfigSummary{
ID: cfg.ID,
Alias: cfg.Alias,
Group: cfg.Group,
TenancyOCID: cfg.TenancyOCID,
TenancyName: cfg.TenancyName,
Region: cfg.Region,
AccountType: cfg.AccountType,
AliveStatus: cfg.AliveStatus,
LastError: cfg.LastError,
LastVerifiedAt: cfg.LastVerifiedAt,
MultiRegion: cfg.MultiRegion,
MultiCompartment: cfg.MultiCompartment,
ProxyID: cfg.ProxyID,
CreatedAt: cfg.CreatedAt,
}
}
// List 返回全部配置的列表摘要。
func (s *OciConfigService) List(ctx context.Context) ([]ConfigSummary, error) {
items := make([]model.OciConfig, 0)
if err := s.db.WithContext(ctx).Order("id").Find(&items).Error; err != nil {
return nil, fmt.Errorf("list oci configs: %w", err)
}
names, err := s.proxyNames(ctx)
if err != nil {
return nil, err
}
summaries := make([]ConfigSummary, len(items))
for i, cfg := range items {
summaries[i] = toConfigSummary(cfg)
if cfg.ProxyID != nil {
summaries[i].ProxyName = names[*cfg.ProxyID]
}
}
return summaries, nil
}
// proxyNames 一次取回代理 id → 名称映射,避免列表逐行查询。
func (s *OciConfigService) proxyNames(ctx context.Context) (map[uint]string, error) {
rows := []model.Proxy{}
if err := s.db.WithContext(ctx).Select("id", "name").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list proxy names: %w", err)
}
out := make(map[uint]string, len(rows))
for _, r := range rows {
out[r.ID] = r.Name
}
return out, nil
}
// Get 返回单个配置快照。
func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig, error) {
var cfg model.OciConfig
if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil {
return nil, fmt.Errorf("find oci config %d: %w", id, err)
}
return &cfg, nil
}
// Delete 删除配置及其区域 / 区间缓存。
func (s *OciConfigService) Delete(ctx context.Context, id uint) error {
res := s.db.WithContext(ctx).Delete(&model.OciConfig{}, id)
if res.Error != nil {
return fmt.Errorf("delete oci config %d: %w", id, res.Error)
}
if res.RowsAffected == 0 {
return fmt.Errorf("delete oci config %d: %w", id, gorm.ErrRecordNotFound)
}
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.RegionCache{})
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.CompartmentCache{})
return nil
}
// refresh 执行测活与账户信息拉取,并把最新云端状态同步进快照。
// OCI 调用失败体现在快照状态字段,只有本地存储失败才返回 error。
func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) error {
now := time.Now()
cfg.LastVerifiedAt = &now
info, err := s.client.ValidateKey(ctx, cred)
if err != nil {
cfg.AliveStatus = model.AliveStatusDead
cfg.LastError = oci.CompactError(err)
} else {
cfg.AliveStatus = model.AliveStatusAlive
cfg.LastError = ""
cfg.TenancyName = info.Name
cfg.HomeRegionKey = info.HomeRegionKey
s.applyProfile(ctx, cfg, cred)
s.syncScopeCaches(ctx, cfg, cred)
}
if err := s.db.WithContext(ctx).Save(cfg).Error; err != nil {
return fmt.Errorf("save oci config snapshot: %w", err)
}
return nil
}
// applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。
func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
profile, err := s.client.FetchAccountProfile(ctx, cred)
if err != nil {
cfg.AccountType = model.AccountTypeUnknown
cfg.LastError = fmt.Sprintf("fetch account profile: %s", oci.CompactError(err))
return
}
cfg.AccountType = profile.AccountType
cfg.SubscriptionID = profile.SubscriptionID
cfg.PaymentModel = profile.PaymentModel
cfg.SubscriptionTier = profile.SubscriptionTier
cfg.PromotionStatus = profile.PromotionStatus
cfg.PromotionAmount = profile.PromotionAmount
cfg.PromotionExpires = profile.PromotionExpires
}
func (s *OciConfigService) buildCredentials(in ImportInput) (oci.Credentials, error) {
cred := oci.Credentials{
TenancyOCID: in.TenancyOCID,
UserOCID: in.UserOCID,
Region: in.Region,
Fingerprint: in.Fingerprint,
}
if in.ConfigINI != "" {
parsed, err := oci.ParseConfigINI(in.ConfigINI)
if err != nil {
return oci.Credentials{}, err
}
cred = parsed
}
cred.PrivateKey = in.PrivateKey
if cred.Passphrase == "" {
cred.Passphrase = in.Passphrase
}
if err := cred.Validate(); err != nil {
return oci.Credentials{}, err
}
return cred, nil
}
func (s *OciConfigService) storeConfig(alias string, cred oci.Credentials) (*model.OciConfig, error) {
keyEnc, err := s.cipher.EncryptString(cred.PrivateKey)
if err != nil {
return nil, fmt.Errorf("encrypt private key: %w", err)
}
passEnc := ""
if cred.Passphrase != "" {
if passEnc, err = s.cipher.EncryptString(cred.Passphrase); err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
}
cfg := &model.OciConfig{
Alias: alias,
UserOCID: cred.UserOCID,
TenancyOCID: cred.TenancyOCID,
Fingerprint: cred.Fingerprint,
Region: cred.Region,
PrivateKeyEnc: keyEnc,
PassphraseEnc: passEnc,
AccountType: model.AccountTypeUnknown,
AliveStatus: model.AliveStatusUnknown,
}
if err := s.db.Create(cfg).Error; err != nil {
return nil, fmt.Errorf("create oci config: %w", err)
}
return cfg, nil
}
// credentialsOf 从快照解密出完整凭据。
func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, error) {
key, err := s.cipher.DecryptString(cfg.PrivateKeyEnc)
if err != nil {
return oci.Credentials{}, fmt.Errorf("decrypt private key of config %d: %w", cfg.ID, err)
}
pass := ""
if cfg.PassphraseEnc != "" {
if pass, err = s.cipher.DecryptString(cfg.PassphraseEnc); err != nil {
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
}
}
spec, err := s.proxySpecOf(cfg)
if err != nil {
return oci.Credentials{}, err
}
return oci.Credentials{
TenancyOCID: cfg.TenancyOCID,
UserOCID: cfg.UserOCID,
Region: cfg.Region,
Fingerprint: cfg.Fingerprint,
PrivateKey: key,
Passphrase: pass,
Proxy: spec,
}, nil
}
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) {
if cfg.ProxyID == nil {
return nil, nil
}
var row model.Proxy
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil {
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err)
}
password := ""
if row.PasswordEnc != "" {
p, err := s.cipher.DecryptString(row.PasswordEnc)
if err != nil {
return nil, fmt.Errorf("decrypt proxy password: %w", err)
}
password = p
}
return &oci.ProxySpec{
Type: row.Type, Host: row.Host, Port: row.Port,
Username: row.Username, Password: password,
}, nil
}
// diffSnapshots 找出两次快照间云端信息字段的变化。
func diffSnapshots(before, after model.OciConfig) Changes {
fields := map[string][2]string{
"aliveStatus": {before.AliveStatus, after.AliveStatus},
"tenancyName": {before.TenancyName, after.TenancyName},
"homeRegionKey": {before.HomeRegionKey, after.HomeRegionKey},
"accountType": {before.AccountType, after.AccountType},
"subscriptionId": {before.SubscriptionID, after.SubscriptionID},
"paymentModel": {before.PaymentModel, after.PaymentModel},
"subscriptionTier": {before.SubscriptionTier, after.SubscriptionTier},
"promotionStatus": {before.PromotionStatus, after.PromotionStatus},
}
changes := make(Changes)
for name, pair := range fields {
if pair[0] != pair[1] {
changes[name] = pair
}
}
return changes
}
+460
View File
@@ -0,0 +1,460 @@
package service
import (
"context"
"errors"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
const testPEM = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----"
// fakeClient 是 oci.Client 的测试替身,普通测试不访问真实 OCI。
// 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。
type fakeClient struct {
oci.Client
tenancy oci.TenancyInfo
tenancyErr error
profile oci.AccountProfile
profileErr error
regionSubs []oci.RegionSubscription
regionSubsErr error
regionSubsCalls int
compartments []oci.Compartment
compartmentsErr error
compartmentsCalls int
subscribeErr error
limitValues []oci.LimitValue
limitsErr error
limitServices []oci.LimitService
subscriptions []oci.SubscriptionInfo
subDetail oci.SubscriptionDetail
subDetailErr error
instances []oci.Instance
instancesErr error
costItems []oci.CostItem
costErr error
subscribedHomeRegion string
subscribedKey string
limitsQuery oci.LimitsQuery
subDetailID string
launched []oci.CreateInstanceInput
}
func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) {
return f.tenancy, f.tenancyErr
}
func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) {
return f.profile, f.profileErr
}
func (f *fakeClient) ListRegionSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.RegionSubscription, error) {
f.regionSubsCalls++
return f.regionSubs, f.regionSubsErr
}
func (f *fakeClient) ListCompartments(ctx context.Context, cred oci.Credentials) ([]oci.Compartment, error) {
f.compartmentsCalls++
return f.compartments, f.compartmentsErr
}
func (f *fakeClient) SubscribeRegion(ctx context.Context, cred oci.Credentials, homeRegion, regionKey string) error {
f.subscribedHomeRegion = homeRegion
f.subscribedKey = regionKey
return f.subscribeErr
}
func (f *fakeClient) ListLimits(ctx context.Context, cred oci.Credentials, q oci.LimitsQuery) ([]oci.LimitValue, error) {
f.limitsQuery = q
return f.limitValues, f.limitsErr
}
func (f *fakeClient) ListLimitServices(ctx context.Context, cred oci.Credentials, region string) ([]oci.LimitService, error) {
return f.limitServices, nil
}
func (f *fakeClient) ListSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.SubscriptionInfo, error) {
return f.subscriptions, nil
}
func (f *fakeClient) GetSubscription(ctx context.Context, cred oci.Credentials, subscriptionID string) (oci.SubscriptionDetail, error) {
f.subDetailID = subscriptionID
return f.subDetail, f.subDetailErr
}
func (f *fakeClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.launched = append(f.launched, in)
return oci.Instance{DisplayName: in.DisplayName, LifecycleState: "PROVISIONING", FreeformTags: in.FreeformTags}, nil
}
// ListAvailabilityDomains 默认单可用域,抢机自动轮询退化为固定 ad-1。
func (f *fakeClient) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) {
return []string{"fake-ad-1"}, nil
}
func (f *fakeClient) ListInstances(ctx context.Context, cred oci.Credentials, region string) ([]oci.Instance, error) {
return f.instances, f.instancesErr
}
func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) {
return f.costItems, f.costErr
}
func newTestService(t *testing.T, client oci.Client) *OciConfigService {
t.Helper()
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)
}
if err := db.AutoMigrate(
&model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
return NewOciConfigService(db, cipher, client)
}
func trialImportInput() ImportInput {
return ImportInput{
Alias: "试用期",
Group: "教育",
TenancyOCID: "ocid1.tenancy.oc1..t",
UserOCID: "ocid1.user.oc1..u",
Region: "eu-frankfurt-1",
Fingerprint: "aa:bb",
PrivateKey: testPEM,
}
}
func TestImportAutoVerifiesAndClassifies(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{
AccountType: model.AccountTypeTrial,
SubscriptionID: "ocid1.organizationssubscription.oc1..s",
PaymentModel: "FREE_TRIAL",
SubscriptionTier: "FREE_AND_TRIAL",
PromotionStatus: "ACTIVE",
PromotionAmount: 300,
},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if got, want := cfg.AccountType, model.AccountTypeTrial; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if got, want := cfg.TenancyName, "mytenancy"; got != want {
t.Errorf("TenancyName = %q, want %q", got, want)
}
if got, want := cfg.Group, "教育"; got != want {
t.Errorf("Group = %q, want %q", got, want)
}
if cfg.LastVerifiedAt == nil {
t.Error("LastVerifiedAt = nil, want set")
}
if cfg.PrivateKeyEnc == testPEM || cfg.PrivateKeyEnc == "" {
t.Error("PrivateKeyEnc: private key stored without encryption")
}
}
func TestImportDeadKeyStillSaved(t *testing.T) {
client := &fakeClient{tenancyErr: errors.New("NotAuthenticated")}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusDead; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if cfg.LastError == "" {
t.Error("LastError is empty, want failure message")
}
}
func TestImportParsesConfigINI(t *testing.T) {
client := &fakeClient{profileErr: errors.New("skip profile")}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), ImportInput{
Alias: "ini 导入",
ConfigINI: "[DEFAULT]\nuser=ocid1.user.oc1..u\ntenancy=ocid1.tenancy.oc1..t\nfingerprint=ff:ee\nregion=ap-tokyo-1\n",
PrivateKey: testPEM,
})
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.Region, "ap-tokyo-1"; got != want {
t.Errorf("Region = %q, want %q", got, want)
}
if got, want := cfg.UserOCID, "ocid1.user.oc1..u"; got != want {
t.Errorf("UserOCID = %q, want %q", got, want)
}
}
func TestImportProfileFailureDegradesToUnknown(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy"},
profileErr: errors.New("subscription api forbidden"),
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if got, want := cfg.AccountType, model.AccountTypeUnknown; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if cfg.LastError == "" {
t.Error("LastError is empty, want profile failure message")
}
}
func TestImportRejectsInvalidInput(t *testing.T) {
svc := newTestService(t, &fakeClient{})
in := trialImportInput()
in.PrivateKey = "not a pem"
if _, err := svc.Import(context.Background(), in); err == nil {
t.Error("Import with bad key: got nil error, want failure")
}
}
func TestVerifyDetectsChanges(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{
AccountType: model.AccountTypeTrial,
PaymentModel: "FREE_TRIAL",
PromotionStatus: "ACTIVE",
},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
// 模拟云端状态变化:试用到期变免费
client.profile = oci.AccountProfile{
AccountType: model.AccountTypeFree,
PaymentModel: "FREE_TRIAL",
PromotionStatus: "EXPIRED",
}
updated, changes, err := svc.Verify(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got, want := updated.AccountType, model.AccountTypeFree; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if got, want := changes["accountType"], [2]string{model.AccountTypeTrial, model.AccountTypeFree}; got != want {
t.Errorf("changes[accountType] = %v, want %v", got, want)
}
if got, want := changes["promotionStatus"], [2]string{"ACTIVE", "EXPIRED"}; got != want {
t.Errorf("changes[promotionStatus] = %v, want %v", got, want)
}
}
func TestVerifyNoChanges(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{AccountType: model.AccountTypeTrial, PaymentModel: "FREE_TRIAL"},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
_, changes, err := svc.Verify(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if len(changes) != 0 {
t.Errorf("changes = %v, want empty", changes)
}
}
func TestVerifyMissingConfig(t *testing.T) {
svc := newTestService(t, &fakeClient{})
if _, _, err := svc.Verify(context.Background(), 999); err == nil {
t.Error("Verify(999): got nil error, want not found")
}
}
func TestDelete(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if err := svc.Delete(context.Background(), cfg.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := svc.Delete(context.Background(), cfg.ID); err == nil {
t.Error("Delete twice: got nil error, want not found")
}
}
func TestUpdateFields(t *testing.T) {
const newPEM = "-----BEGIN PRIVATE KEY-----\nrotated\n-----END PRIVATE KEY-----"
empty := ""
prod := "生产"
tests := []struct {
name string
in UpdateInput
check func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig)
}{
{
name: "改别名与默认区域",
in: UpdateInput{Alias: "改名后", Region: "ap-tokyo-1"},
check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) {
if cfg.Alias != "改名后" || cfg.Region != "ap-tokyo-1" {
t.Errorf("alias/region = %q/%q, want 改名后/ap-tokyo-1", cfg.Alias, cfg.Region)
}
if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != testPEM {
t.Errorf("private key changed unexpectedly: %v", err)
}
},
},
{
name: "改分组",
in: UpdateInput{Group: &prod},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "生产" {
t.Errorf("Group = %q, want 生产", cfg.Group)
}
},
},
{
name: "清空分组",
in: UpdateInput{Group: &empty},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "" {
t.Errorf("Group = %q, want empty", cfg.Group)
}
},
},
{
name: "缺省分组保持不变",
in: UpdateInput{Alias: "只改名"},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "教育" {
t.Errorf("Group = %q, want 教育 (unchanged)", cfg.Group)
}
},
},
{
name: "换私钥与指纹",
in: UpdateInput{PrivateKey: newPEM, Fingerprint: "cc:dd"},
check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) {
if cfg.Fingerprint != "cc:dd" {
t.Errorf("fingerprint = %q, want cc:dd", cfg.Fingerprint)
}
if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != newPEM {
t.Errorf("private key not rotated: %v", err)
}
},
},
{
name: "清除口令",
in: UpdateInput{Passphrase: &empty},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.PassphraseEnc != "" {
t.Errorf("PassphraseEnc = %q, want empty", cfg.PassphraseEnc)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
updated, _, err := svc.Update(context.Background(), cfg.ID, tt.in)
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.LastVerifiedAt == nil || !updated.LastVerifiedAt.After(*cfg.LastVerifiedAt) {
t.Error("Update should re-verify: LastVerifiedAt not advanced")
}
tt.check(t, svc, updated)
})
}
}
func TestUpdateKeyRequiresFingerprint(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{PrivateKey: "x"}); err == nil {
t.Error("Update with key but no fingerprint: got nil error, want rejection")
}
}
func TestListReturnsSummaries(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "mytenancy"}})
first, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import first: %v", err)
}
second := trialImportInput()
second.Alias = "生产号"
second.Group = ""
if _, err := svc.Import(context.Background(), second); err != nil {
t.Fatalf("Import second: %v", err)
}
items, err := svc.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 2 {
t.Fatalf("len(items) = %d, want 2", len(items))
}
got := items[0]
want := ConfigSummary{
ID: first.ID, Alias: "试用期", Group: "教育",
TenancyOCID: first.TenancyOCID, TenancyName: "mytenancy", Region: first.Region,
AccountType: first.AccountType, AliveStatus: model.AliveStatusAlive,
LastVerifiedAt: got.LastVerifiedAt, CreatedAt: got.CreatedAt,
}
if got != want {
t.Errorf("items[0] = %+v, want %+v", got, want)
}
if items[1].Group != "" || items[1].Alias != "生产号" {
t.Errorf("items[1] alias/group = %q/%q, want 生产号/empty", items[1].Alias, items[1].Group)
}
}
+196
View File
@@ -0,0 +1,196 @@
package service
import (
"context"
"fmt"
"time"
"oci-portal/internal/model"
)
// OverviewTenants 是租户数量与类别分布。
type OverviewTenants struct {
Total int `json:"total"`
Alive int `json:"alive"`
Dead int `json:"dead"`
ByType map[string]int `json:"byType"`
}
// OverviewCheck 是测活快照聚合;实例数为各配置默认区域之和,
// CoveredConfigs/TotalConfigs 用于前端标注覆盖度。
type OverviewCheck struct {
HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"`
TotalConfigs int `json:"totalConfigs"`
InstanceCount int `json:"instanceCount"`
LastCheckedAt *time.Time `json:"lastCheckedAt"`
}
// OverviewCostDay 是一天的跨租户成本合计。
type OverviewCostDay struct {
Day string `json:"day"`
Amount float64 `json:"amount"`
}
// OverviewCost 是近 7 日成本快照聚合;金额跨配置直接相加,
// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。
type OverviewCost struct {
HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"`
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
}
// OverviewTasks 是后台任务数量统计。
type OverviewTasks struct {
Total int `json:"total"`
Active int `json:"active"`
HealthCheck int `json:"healthCheck"`
Cost int `json:"cost"`
Snatch int `json:"snatch"`
}
// Overview 是总览页聚合数据。
type Overview struct {
Tenants OverviewTenants `json:"tenants"`
Check OverviewCheck `json:"check"`
Cost OverviewCost `json:"cost"`
Tasks OverviewTasks `json:"tasks"`
}
// Overview 聚合总览数据:租户分布、测活/成本快照与任务统计,全部读本地库不发云端请求。
func (s *OciConfigService) Overview(ctx context.Context) (*Overview, error) {
var out Overview
if err := s.overviewTenants(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewCheck(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewCost(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewTasks(ctx, &out); err != nil {
return nil, err
}
return &out, nil
}
func (s *OciConfigService) overviewTenants(ctx context.Context, out *Overview) error {
configs, err := s.List(ctx)
if err != nil {
return err
}
out.Tenants.Total = len(configs)
out.Tenants.ByType = map[string]int{}
for _, cfg := range configs {
out.Tenants.ByType[cfg.AccountType]++
switch cfg.AliveStatus {
case model.AliveStatusAlive:
out.Tenants.Alive++
case model.AliveStatusDead:
out.Tenants.Dead++
}
}
return nil
}
// hasActiveTask 判断是否存在指定类型且调度中的任务。
func (s *OciConfigService) hasActiveTask(ctx context.Context, taskType string) (bool, error) {
var n int64
err := s.db.WithContext(ctx).Model(&model.Task{}).
Where("type = ? AND status = ?", taskType, model.TaskStatusActive).Count(&n).Error
if err != nil {
return false, fmt.Errorf("count %s tasks: %w", taskType, err)
}
return n > 0, nil
}
func (s *OciConfigService) overviewCheck(ctx context.Context, out *Overview) error {
has, err := s.hasActiveTask(ctx, model.TaskTypeHealthCheck)
if err != nil {
return err
}
out.Check.HasActiveTask = has
out.Check.TotalConfigs = out.Tenants.Total
var snaps []model.CheckSnapshot
err = s.db.WithContext(ctx).
Where("oci_config_id IN (?)", s.db.Model(&model.OciConfig{}).Select("id")).
Find(&snaps).Error
if err != nil {
return fmt.Errorf("load check snapshots: %w", err)
}
out.Check.CoveredConfigs = len(snaps)
for _, snap := range snaps {
out.Check.InstanceCount += snap.InstanceCount
if out.Check.LastCheckedAt == nil || snap.CheckedAt.After(*out.Check.LastCheckedAt) {
t := snap.CheckedAt
out.Check.LastCheckedAt = &t
}
}
return nil
}
func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) error {
has, err := s.hasActiveTask(ctx, model.TaskTypeCost)
if err != nil {
return err
}
out.Cost.HasActiveTask = has
since := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
var snaps []model.CostSnapshot
err = s.db.WithContext(ctx).
Where("day >= ? AND oci_config_id IN (?)", since, s.db.Model(&model.OciConfig{}).Select("id")).
Order("day").Find(&snaps).Error
if err != nil {
return fmt.Errorf("load cost snapshots: %w", err)
}
aggregateCostDays(&out.Cost, snaps)
return nil
}
// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序)。
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
byDay := map[string]float64{}
covered := map[uint]bool{}
var order []string
for _, snap := range snaps {
if _, ok := byDay[snap.Day]; !ok {
order = append(order, snap.Day)
}
byDay[snap.Day] += snap.Amount
covered[snap.OciConfigID] = true
if cost.Currency == "" {
cost.Currency = snap.Currency
}
}
cost.CoveredConfigs = len(covered)
cost.Days = make([]OverviewCostDay, 0, len(order))
for _, day := range order {
cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
cost.Total += byDay[day]
}
}
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
var tasks []model.Task
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
return fmt.Errorf("list tasks: %w", err)
}
out.Tasks.Total = len(tasks)
for _, t := range tasks {
if t.Status == model.TaskStatusActive {
out.Tasks.Active++
}
switch t.Type {
case model.TaskTypeHealthCheck:
out.Tasks.HealthCheck++
case model.TaskTypeCost:
out.Tasks.Cost++
case model.TaskTypeSnatch:
out.Tasks.Snatch++
}
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package service
import (
"context"
"math"
"testing"
"time"
"gorm.io/gorm"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
func costItem(day string, amount float32, currency string) oci.CostItem {
t, _ := time.Parse("2006-01-02", day)
return oci.CostItem{TimeStart: &t, ComputedAmount: amount, Currency: currency}
}
func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
costItems: []oci.CostItem{
costItem("2026-07-01", 5.2, "USD"),
costItem("2026-07-01", 1.4, "USD"),
costItem("2026-07-02", 6.3, "USD"),
},
}
tasks, configs, db := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
free := trialImportInput()
free.Alias = "免费号"
if _, err := configs.Import(ctx, free); err != nil {
t.Fatalf("import free: %v", err)
}
if err := db.Model(&model.OciConfig{}).Where("id = ?", 2).
Update("account_type", model.AccountTypeFree).Error; err != nil {
t.Fatalf("mark free: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil {
t.Fatalf("RunTaskNow: %v", err)
}
if !entry.Success || entry.Message != "synced usage for 1 tenants, skipped 1 free" {
t.Errorf("log = %+v, want synced 1 skipped 1", entry)
}
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3})
// 再次执行为覆盖更新,不产生重复行
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("RunTaskNow again: %v", err)
}
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3})
}
// assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。
func assertCostSnapshots(t *testing.T, db *gorm.DB, want map[string]float64) {
t.Helper()
var snaps []model.CostSnapshot
if err := db.Where("oci_config_id = ?", 1).Find(&snaps).Error; err != nil {
t.Fatalf("load cost snapshots: %v", err)
}
if len(snaps) != len(want) {
t.Fatalf("snapshots = %d rows, want %d", len(snaps), len(want))
}
for _, snap := range snaps {
amount, ok := want[snap.Day]
// 金额自 float32 累加而来,按容差比较
if !ok || math.Abs(snap.Amount-amount) > 1e-4 {
t.Errorf("day %s amount = %v, want %v", snap.Day, snap.Amount, want[snap.Day])
}
}
}
func TestRunHealthCheckWritesSnapshot(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
instances: []oci.Instance{{ID: "i1"}, {ID: "i2"}},
}
tasks, configs, db := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("RunTaskNow: %v", err)
}
var snap model.CheckSnapshot
if err := db.Where("oci_config_id = ?", 1).First(&snap).Error; err != nil {
t.Fatalf("load snapshot: %v", err)
}
if snap.AliveStatus != model.AliveStatusAlive || snap.InstanceCount != 2 {
t.Errorf("snapshot = %+v, want alive with 2 instances", snap)
}
}
func TestOverviewAggregates(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
instances: []oci.Instance{{ID: "i1"}},
costItems: []oci.CostItem{costItem(time.Now().UTC().Format("2006-01-02"), 3.5, "USD")},
}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
for _, in := range []CreateTaskInput{
{Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *"},
{Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *"},
} {
task, err := tasks.CreateTask(ctx, in)
if err != nil {
t.Fatalf("CreateTask %s: %v", in.Name, err)
}
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("RunTaskNow %s: %v", in.Name, err)
}
}
out, err := configs.Overview(ctx)
if err != nil {
t.Fatalf("Overview: %v", err)
}
tests := []struct {
name string
got interface{}
want interface{}
}{
{"租户总数", out.Tenants.Total, 1},
{"存活数", out.Tenants.Alive, 1},
{"测活任务存在", out.Check.HasActiveTask, true},
{"测活覆盖", out.Check.CoveredConfigs, 1},
{"实例数", out.Check.InstanceCount, 1},
{"成本任务存在", out.Cost.HasActiveTask, true},
{"成本覆盖", out.Cost.CoveredConfigs, 1},
{"成本合计", out.Cost.Total, 3.5},
{"币种", out.Cost.Currency, "USD"},
{"任务总数", out.Tasks.Total, 2},
{"active 任务", out.Tasks.Active, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("got %v, want %v", tt.got, tt.want)
}
})
}
if len(out.Cost.Days) != 1 {
t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
}
}
+253
View File
@@ -0,0 +1,253 @@
package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// 代理配置错误;api 层映射 400 / 409。
var (
// ErrProxyInvalid 表示字段校验失败。
ErrProxyInvalid = errors.New("代理配置非法")
// ErrProxyInUse 表示代理仍被租户引用,拒绝删除。
ErrProxyInUse = errors.New("代理仍被租户关联,请先解除关联")
)
// ProxyService 管理出站代理配置;密码 AES-GCM 加密落库。
type ProxyService struct {
db *gorm.DB
cipher *crypto.Cipher
// wg 追踪创建 / 更新后的异步出口地理探测,Wait 供优雅关停与测试收敛
wg sync.WaitGroup
// geoClientFor 构造经代理出站的探测 client;测试注入替身避免真实外呼
geoClientFor func(*oci.ProxySpec) *http.Client
}
// NewProxyService 组装依赖。
func NewProxyService(db *gorm.DB, cipher *crypto.Cipher) *ProxyService {
return &ProxyService{db: db, cipher: cipher, geoClientFor: oci.HTTPClientFor}
}
// Wait 阻塞至在途的地理探测全部完成;进程退出前调用。
func (s *ProxyService) Wait() {
s.wg.Wait()
}
// ProxyView 是代理的脱敏视图,绝不含密码明文。
type ProxyView struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
PasswordSet bool `json:"passwordSet"`
// Country / City 为出口实测地区;GeoAt 空串表示尚未探测(前端显示「检测中」)
Country string `json:"country"`
City string `json:"city"`
GeoAt string `json:"geoAt"`
// UsedBy 为关联此代理的租户数,前端据此提示删除约束
UsedBy int64 `json:"usedBy"`
CreatedAt string `json:"createdAt"`
}
// ProxyInput 是创建 / 更新请求体;Name 缺省自动生成,
// Password 为 nil 表示沿用已存值,空串表示清除。
type ProxyInput struct {
Name string `json:"name"`
Type string `json:"type" binding:"required"`
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
Username string `json:"username"`
Password *string `json:"password"`
}
// validateProxyInput 校验类型 / 端口 / 主机;错误信息用户可读。
func validateProxyInput(in ProxyInput) error {
if in.Type != "socks5" && in.Type != "http" && in.Type != "https" {
return fmt.Errorf("类型仅支持 socks5 / http / https: %w", ErrProxyInvalid)
}
if in.Port < 1 || in.Port > 65535 {
return fmt.Errorf("端口须在 1-65535 之间: %w", ErrProxyInvalid)
}
if strings.TrimSpace(in.Host) == "" {
return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid)
}
return nil
}
// List 返回全部代理的脱敏视图,附带引用计数。
func (s *ProxyService) List(ctx context.Context) ([]ProxyView, error) {
rows := []model.Proxy{}
if err := s.db.WithContext(ctx).Order("id").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list proxies: %w", err)
}
out := make([]ProxyView, 0, len(rows))
for i := range rows {
v, err := s.viewOf(ctx, &rows[i])
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// viewOf 组装单条脱敏视图。
func (s *ProxyService) viewOf(ctx context.Context, p *model.Proxy) (ProxyView, error) {
var used int64
err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", p.ID).Count(&used).Error
if err != nil {
return ProxyView{}, fmt.Errorf("count proxy refs: %w", err)
}
return ProxyView{
ID: p.ID, Name: p.Name, Type: p.Type, Host: p.Host, Port: p.Port,
Username: p.Username, PasswordSet: p.PasswordEnc != "", UsedBy: used,
Country: p.Country, City: p.City, GeoAt: formatTimePtr(p.GeoAt),
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}, nil
}
// formatTimePtr 把可空时间格式化为 RFC3339,nil 返回空串。
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02T15:04:05Z07:00")
}
// autoProxyName 名称缺省时生成 `{type}-{host}:{port}`,与现有代理重名时追加序号后缀。
func (s *ProxyService) autoProxyName(ctx context.Context, in ProxyInput, selfID uint) string {
base := fmt.Sprintf("%s-%s:%d", in.Type, strings.TrimSpace(in.Host), in.Port)
if len(base) > 56 {
base = base[:56]
}
name := base
for i := 2; i <= 999; i++ {
var n int64
s.db.WithContext(ctx).Model(&model.Proxy{}).
Where("name = ? AND id <> ?", name, selfID).Count(&n)
if n == 0 {
return name
}
name = fmt.Sprintf("%s-%d", base, i)
}
return name
}
// Create 新建代理;名称唯一冲突返回可读错误,成功后异步探测出口地区。
func (s *ProxyService) Create(ctx context.Context, in ProxyInput) (ProxyView, error) {
if err := validateProxyInput(in); err != nil {
return ProxyView{}, err
}
if strings.TrimSpace(in.Name) == "" {
in.Name = s.autoProxyName(ctx, in, 0)
}
row := model.Proxy{
Name: strings.TrimSpace(in.Name), Type: in.Type,
Host: strings.TrimSpace(in.Host), Port: in.Port, Username: in.Username,
}
if err := s.fillPassword(&row, in.Password); err != nil {
return ProxyView{}, err
}
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
return ProxyView{}, fmt.Errorf("create proxy: %w", err)
}
s.probeGeoAsync(row.ID)
return s.viewOf(ctx, &row)
}
// Update 更新代理;密码缺省沿用、空串清除,名称留空重新自动生成,成功后重测地区。
func (s *ProxyService) Update(ctx context.Context, id uint, in ProxyInput) (ProxyView, error) {
if err := validateProxyInput(in); err != nil {
return ProxyView{}, err
}
if strings.TrimSpace(in.Name) == "" {
in.Name = s.autoProxyName(ctx, in, id)
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err)
}
row.Name, row.Type = strings.TrimSpace(in.Name), in.Type
row.Host, row.Port, row.Username = strings.TrimSpace(in.Host), in.Port, in.Username
if err := s.fillPassword(&row, in.Password); err != nil {
return ProxyView{}, err
}
if err := s.db.WithContext(ctx).Save(&row).Error; err != nil {
return ProxyView{}, fmt.Errorf("update proxy: %w", err)
}
s.probeGeoAsync(row.ID)
return s.viewOf(ctx, &row)
}
// fillPassword 按输入语义写密文:nil 沿用、空串清除、非空加密覆盖。
func (s *ProxyService) fillPassword(row *model.Proxy, password *string) error {
if password == nil {
return nil
}
if *password == "" {
row.PasswordEnc = ""
return nil
}
enc, err := s.cipher.EncryptString(*password)
if err != nil {
return fmt.Errorf("encrypt proxy password: %w", err)
}
row.PasswordEnc = enc
return nil
}
// Delete 删除代理;仍被租户引用时拒绝。
func (s *ProxyService) Delete(ctx context.Context, id uint) error {
var used int64
err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", id).Count(&used).Error
if err != nil {
return fmt.Errorf("count proxy refs: %w", err)
}
if used > 0 {
return ErrProxyInUse
}
res := s.db.WithContext(ctx).Delete(&model.Proxy{}, id)
if res.Error != nil {
return fmt.Errorf("delete proxy: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。
func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) {
if id == nil {
return nil, nil
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, *id).Error; err != nil {
return nil, fmt.Errorf("find proxy %d: %w", *id, err)
}
password := ""
if row.PasswordEnc != "" {
p, err := s.cipher.DecryptString(row.PasswordEnc)
if err != nil {
return nil, fmt.Errorf("decrypt proxy password: %w", err)
}
password = p
}
return &oci.ProxySpec{
Type: row.Type, Host: row.Host, Port: row.Port,
Username: row.Username, Password: password,
}, nil
}
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
func newProxyEnv(t *testing.T) (*ProxyService, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Proxy{}, &model.OciConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("cipher: %v", err)
}
svc := NewProxyService(db, cipher)
// 默认注入空 client:异步探测直接跳过,避免测试外呼与写库竞态
svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return nil }
t.Cleanup(svc.Wait)
return svc, db
}
func strp(s string) *string { return &s }
func TestProxyCRUDAndPasswordSemantics(t *testing.T) {
svc, db := newProxyEnv(t)
ctx := context.Background()
created, err := svc.Create(ctx, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1080, Username: "u", Password: strp("secret")})
if err != nil {
t.Fatalf("create: %v", err)
}
if !created.PasswordSet {
t.Fatalf("PasswordSet = false, want true")
}
var row model.Proxy
if err := db.First(&row, created.ID).Error; err != nil {
t.Fatalf("load row: %v", err)
}
if row.PasswordEnc == "" || row.PasswordEnc == "secret" {
t.Fatalf("password must be stored encrypted, got %q", row.PasswordEnc)
}
// 密码缺省沿用
updated, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081})
if err != nil {
t.Fatalf("update: %v", err)
}
if !updated.PasswordSet || updated.Port != 1081 {
t.Fatalf("update view = %+v, want password kept and port 1081", updated)
}
// 空串清除
cleared, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081, Password: strp("")})
if err != nil {
t.Fatalf("update clear: %v", err)
}
if cleared.PasswordSet {
t.Fatalf("PasswordSet = true after clear, want false")
}
spec, err := svc.SpecOf(ctx, &created.ID)
if err != nil {
t.Fatalf("SpecOf: %v", err)
}
if spec == nil || spec.Host != "1.2.3.4" || spec.Port != 1081 {
t.Fatalf("spec = %+v", spec)
}
if spec2, err := svc.SpecOf(ctx, nil); err != nil || spec2 != nil {
t.Fatalf("SpecOf(nil) = %+v, %v; want nil, nil", spec2, err)
}
if err := svc.Delete(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}
func TestProxyValidateAndDeleteInUse(t *testing.T) {
svc, db := newProxyEnv(t)
ctx := context.Background()
for _, in := range []ProxyInput{
{Name: "x", Type: "ss", Host: "h", Port: 1080},
{Name: "x", Type: "http", Host: "h", Port: 0},
{Name: "x", Type: "http", Host: " ", Port: 8080},
} {
if _, err := svc.Create(ctx, in); err == nil {
t.Fatalf("Create(%+v) accepted invalid input", in)
}
}
created, err := svc.Create(ctx, ProxyInput{Name: "used", Type: "http", Host: "h", Port: 8080})
if err != nil {
t.Fatalf("create: %v", err)
}
cfg := model.OciConfig{Alias: "t1", ProxyID: &created.ID}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create cfg: %v", err)
}
if err := svc.Delete(ctx, created.ID); err == nil {
t.Fatalf("Delete allowed while in use, want ErrProxyInUse")
}
views, err := svc.List(ctx)
if err != nil || len(views) != 1 || views[0].UsedBy != 1 {
t.Fatalf("List = %+v, %v; want one proxy with UsedBy=1", views, err)
}
}
func TestProxyAutoNameAndImport(t *testing.T) {
svc, _ := newProxyEnv(t)
ctx := context.Background()
// 名称缺省自动生成;同 host:port 再建追加序号
v1, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
if err != nil || v1.Name != "socks5-1.2.3.4:1080" {
t.Fatalf("auto name = %q, %v; want socks5-1.2.3.4:1080", v1.Name, err)
}
v2, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
if err != nil || v2.Name != "socks5-1.2.3.4:1080-2" {
t.Fatalf("auto name #2 = %q, %v; want socks5-1.2.3.4:1080-2", v2.Name, err)
}
text := "# 注释与空行跳过\n\n" +
"socks5://u1:p1@9.9.9.9:1080\n" +
"https://8.8.8.8:8443\n" +
"7.7.7.7:1080:u2:p2\n" +
"badline\n"
res, err := svc.Import(ctx, text)
if err != nil {
t.Fatalf("import: %v", err)
}
if len(res.Created) != 3 || len(res.Failed) != 1 {
t.Fatalf("import = %d created / %d failed, want 3 / 1: %+v", len(res.Created), len(res.Failed), res)
}
if res.Created[0].Type != "socks5" || !res.Created[0].PasswordSet || res.Created[0].Username != "u1" {
t.Fatalf("URI 行解析 = %+v, want socks5 带凭据", res.Created[0])
}
if res.Created[1].Type != "https" || res.Created[1].Port != 8443 {
t.Fatalf("https 行解析 = %+v", res.Created[1])
}
if res.Created[2].Type != "socks5" || res.Created[2].Username != "u2" || !res.Created[2].PasswordSet {
t.Fatalf("colon 行解析 = %+v, want socks5 带凭据", res.Created[2])
}
if res.Failed[0].LineNo != 6 || res.Failed[0].Reason == "" {
t.Fatalf("failed 行 = %+v, want lineNo=6 带原因", res.Failed[0])
}
}
func TestProxyImportMasksCredentials(t *testing.T) {
svc, _ := newProxyEnv(t)
ctx := context.Background()
// 端口非法的行会失败并回显:凭据段必须已脱敏
res, err := svc.Import(ctx, "socks5://user:topsecret@1.2.3.4:notaport\n1.2.3.4:notaport:user:topsecret")
if err != nil || len(res.Failed) != 2 {
t.Fatalf("import = %+v, %v; want 2 failed", res, err)
}
for _, f := range res.Failed {
if strings.Contains(f.Line, "topsecret") {
t.Fatalf("failed line %q 泄露密码", f.Line)
}
if !strings.Contains(f.Line, "***") {
t.Fatalf("failed line %q 未打码", f.Line)
}
}
}
func TestProxyGeoProbe(t *testing.T) {
svc, db := newProxyEnv(t)
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ok" {
_, _ = w.Write([]byte(`{"status":"success","country":"日本","city":"大阪市"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
orig := geoEndpoints
defer func() { geoEndpoints = orig }()
// 首个端点失败,验证 fallback 到第二个
geoEndpoints = []geoEndpoint{
{url: srv.URL + "/fail", parse: parseIPAPI},
{url: srv.URL + "/ok", parse: parseIPAPI},
}
svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return srv.Client() }
created, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
if err != nil {
t.Fatalf("create: %v", err)
}
svc.Wait()
var row model.Proxy
if err := db.First(&row, created.ID).Error; err != nil {
t.Fatalf("load row: %v", err)
}
if row.Country != "日本" || row.City != "大阪市" || row.GeoAt == nil {
t.Fatalf("geo = %q/%q geoAt=%v, want 日本/大阪市 带时间", row.Country, row.City, row.GeoAt)
}
// 全部端点失败:Probe 写空值+时间(未知),不残留旧地区
geoEndpoints = []geoEndpoint{{url: srv.URL + "/fail", parse: parseIPAPI}}
view, err := svc.Probe(ctx, created.ID)
if err != nil {
t.Fatalf("probe: %v", err)
}
if view.Country != "" || view.City != "" || view.GeoAt == "" {
t.Fatalf("probe view = %+v, want 空地区带探测时间", view)
}
}
+133
View File
@@ -0,0 +1,133 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"oci-portal/internal/model"
)
// geoProbeTimeout 覆盖单次探测(含 fallback)的总时长。
const geoProbeTimeout = 20 * time.Second
// geoResult 是代理出口地理探测结果;空值表示探测失败(前端显示「未知」)。
type geoResult struct {
Country string
City string
}
// geoEndpoint 是单个出口地理 API 的地址与解析器。
type geoEndpoint struct {
url string
parse func([]byte) (geoResult, bool)
}
// geoEndpoints 依次尝试的免费出口地理 API,第一个成功即返回。
// 主选 ip-api.com:免费 45 req/min(按出口 IP 计)、直接返回中文,但仅提供 http;
// 备选 ipwho.is:https、无显式限频,仅英文。均不带 IP 参数,即查代理出口本身。
var geoEndpoints = []geoEndpoint{
{url: "http://ip-api.com/json/?fields=status,country,city&lang=zh-CN", parse: parseIPAPI},
{url: "https://ipwho.is/?fields=success,country,city", parse: parseIPWhois},
}
// parseIPAPI 解析 ip-api.com 响应。
func parseIPAPI(b []byte) (geoResult, bool) {
var r struct {
Status string `json:"status"`
Country string `json:"country"`
City string `json:"city"`
}
if json.Unmarshal(b, &r) != nil || r.Status != "success" || r.Country == "" {
return geoResult{}, false
}
return geoResult{Country: r.Country, City: r.City}, true
}
// parseIPWhois 解析 ipwho.is 响应。
func parseIPWhois(b []byte) (geoResult, bool) {
var r struct {
Success bool `json:"success"`
Country string `json:"country"`
City string `json:"city"`
}
if json.Unmarshal(b, &r) != nil || !r.Success || r.Country == "" {
return geoResult{}, false
}
return geoResult{Country: r.Country, City: r.City}, true
}
// probeGeoOnce 经指定 client 请求单个 geo API;任何失败返回 false。
func probeGeoOnce(ctx context.Context, hc *http.Client, url string, parse func([]byte) (geoResult, bool)) (geoResult, bool) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return geoResult{}, false
}
resp, err := hc.Do(req)
if err != nil {
return geoResult{}, false
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if err != nil || resp.StatusCode != http.StatusOK {
return geoResult{}, false
}
return parse(body)
}
// probeGeoAsync 后台探测出口地理并回填;创建 / 更新后调用,Wait 收敛。
func (s *ProxyService) probeGeoAsync(id uint) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), geoProbeTimeout)
defer cancel()
s.probeGeo(ctx, id)
}()
}
// probeGeo 经该代理链路依次尝试 geo API 并写回地区字段;
// 请求全部失败也写空值+探测时间(前端显示「未知」),避免主机变更后残留旧地区;
// client 构造失败(spec 非法)则不动数据。
func (s *ProxyService) probeGeo(ctx context.Context, id uint) {
spec, err := s.SpecOf(ctx, &id)
if err != nil {
return
}
hc := s.geoClientFor(spec)
if hc == nil {
return
}
res := geoResult{}
for _, ep := range geoEndpoints {
if r, ok := probeGeoOnce(ctx, hc, ep.url, ep.parse); ok {
res = r
break
}
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return
}
now := time.Now()
row.Country, row.City, row.GeoAt = res.Country, res.City, &now
s.db.WithContext(ctx).Save(&row)
}
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
func (s *ProxyService) Probe(ctx context.Context, id uint) (ProxyView, error) {
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err)
}
pctx, cancel := context.WithTimeout(ctx, geoProbeTimeout)
defer cancel()
s.probeGeo(pctx, id)
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err)
}
return s.viewOf(ctx, &row)
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// ImportResult 是批量导入的汇总结果。
type ImportResult struct {
Created []ProxyView `json:"created"`
Failed []ImportFail `json:"failed"`
}
// ImportFail 记录解析或入库失败的行;Line 已脱敏,凭据段以 *** 代替。
type ImportFail struct {
LineNo int `json:"lineNo"`
Line string `json:"line"`
Reason string `json:"reason"`
}
// Import 逐行解析并创建代理;空行与 # 注释跳过,失败行不阻断后续。
func (s *ProxyService) Import(ctx context.Context, text string) (ImportResult, error) {
res := ImportResult{Created: []ProxyView{}, Failed: []ImportFail{}}
for i, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
in, err := parseProxyLine(line)
if err == nil {
var view ProxyView
if view, err = s.Create(ctx, in); err == nil {
res.Created = append(res.Created, view)
continue
}
}
res.Failed = append(res.Failed, ImportFail{
LineNo: i + 1, Line: maskProxyLine(line), Reason: err.Error(),
})
}
return res, nil
}
// parseProxyLine 解析一行代理,支持两种格式:
//
// scheme://[user:pass@]host:port(scheme 为 socks5 / http / https)
// host:port[:user:pass](缺省按 socks5)
func parseProxyLine(line string) (ProxyInput, error) {
if strings.Contains(line, "://") {
return parseProxyURI(line)
}
parts := strings.Split(line, ":")
if len(parts) != 2 && len(parts) != 4 {
return ProxyInput{}, fmt.Errorf("格式应为 host:port 或 host:port:user:pass: %w", ErrProxyInvalid)
}
port, err := strconv.Atoi(parts[1])
if err != nil {
return ProxyInput{}, fmt.Errorf("端口非数字: %w", ErrProxyInvalid)
}
in := ProxyInput{Type: "socks5", Host: parts[0], Port: port}
if len(parts) == 4 {
in.Username = parts[2]
in.Password = &parts[3]
}
return in, nil
}
// parseProxyURI 解析 scheme://[user:pass@]host:port。
func parseProxyURI(line string) (ProxyInput, error) {
u, err := url.Parse(line)
if err != nil {
return ProxyInput{}, fmt.Errorf("URI 解析失败: %w", ErrProxyInvalid)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
return ProxyInput{}, fmt.Errorf("缺少或非法端口: %w", ErrProxyInvalid)
}
in := ProxyInput{Type: u.Scheme, Host: u.Hostname(), Port: port}
if u.User != nil {
in.Username = u.User.Username()
if pw, ok := u.User.Password(); ok {
in.Password = &pw
}
}
return in, nil
}
// maskProxyLine 把行内可能的凭据段替换为 ***,避免密码回显到响应。
func maskProxyLine(line string) string {
if i := strings.Index(line, "://"); i >= 0 {
if j := strings.LastIndex(line, "@"); j > i {
return line[:i+3] + "***@" + line[j+1:]
}
return line
}
if parts := strings.Split(line, ":"); len(parts) > 2 {
return strings.Join(parts[:2], ":") + ":***"
}
return line
}
+94
View File
@@ -0,0 +1,94 @@
package service
import (
"context"
"fmt"
"strings"
"oci-portal/internal/oci"
)
// RegionSubscriptionView 在云端订阅信息之上补充本地维护的友好名称。
type RegionSubscriptionView struct {
oci.RegionSubscription
Alias string `json:"alias"`
}
// RegionSubscriptions 返回租户已订阅的区域列表(订阅 Tab 使用 SDK 实时数据);
// 开启多区域支持时与缓存比对,有差异即入库,保持缓存跟随真实状态。
func (s *OciConfigService) RegionSubscriptions(ctx context.Context, id uint) ([]RegionSubscriptionView, error) {
cfg, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
cred, err := s.credentialsOf(cfg)
if err != nil {
return nil, err
}
subs, err := s.client.ListRegionSubscriptions(ctx, cred)
if err != nil {
return nil, err
}
if cfg.MultiRegion {
s.reconcileRegionCache(ctx, cfg.ID, subs)
}
return enrichRegionAlias(subs), nil
}
// SubscribeRegion 为租户订阅新区域并返回最新订阅列表。
// OCI 区域订阅一经创建不可取消。
func (s *OciConfigService) SubscribeRegion(ctx context.Context, id uint, regionKey string) ([]RegionSubscriptionView, error) {
regionKey = strings.ToUpper(strings.TrimSpace(regionKey))
if regionKey == "" {
return nil, fmt.Errorf("subscribe region: region key is empty")
}
cfg, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
cred, err := s.credentialsOf(cfg)
if err != nil {
return nil, err
}
// 订阅请求必须发往 home region;本地表查不到时退回配置默认区域
if err := s.client.SubscribeRegion(ctx, cred, homeRegionOf(cfg), regionKey); err != nil {
return nil, err
}
subs, err := s.client.ListRegionSubscriptions(ctx, cred)
if err != nil {
return nil, err
}
// 订阅成功入库(新区域 IN_PROGRESS 状态随缓存读取持续刷新直到 READY)
if cfg.MultiRegion {
_ = s.saveRegionCache(ctx, cfg.ID, subs)
}
return enrichRegionAlias(subs), nil
}
// Limits 查询租户在指定区域、服务下的配额。
func (s *OciConfigService) Limits(ctx context.Context, id uint, q oci.LimitsQuery) ([]oci.LimitValue, error) {
if q.Service == "" {
return nil, fmt.Errorf("list limits: service is empty")
}
cfg, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
cred, err := s.credentialsOf(cfg)
if err != nil {
return nil, err
}
return s.client.ListLimits(ctx, cred, q)
}
func enrichRegionAlias(subs []oci.RegionSubscription) []RegionSubscriptionView {
views := make([]RegionSubscriptionView, 0, len(subs))
for _, sub := range subs {
view := RegionSubscriptionView{RegionSubscription: sub}
if info, ok := oci.RegionByKey(sub.Key); ok {
view.Alias = info.Alias
}
views = append(views, view)
}
return views
}
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"context"
"errors"
"testing"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// importAliveConfig 导入一份测活成功、home region 为 FRA 的配置。
func importAliveConfig(t *testing.T, svc *OciConfigService) *model.OciConfig {
t.Helper()
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
return cfg
}
func TestRegionSubscriptionsEnrichesAlias(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
regionSubs: []oci.RegionSubscription{
{Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true},
{Key: "XXX", Name: "not-in-local-table", Status: "READY"},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
subs, err := svc.RegionSubscriptions(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("RegionSubscriptions: %v", err)
}
if len(subs) != 2 {
t.Fatalf("len(subs) = %d, want 2", len(subs))
}
if got, want := subs[0].Alias, "Germany Central (Frankfurt)"; got != want {
t.Errorf("subs[0].Alias = %q, want %q", got, want)
}
if got, want := subs[1].Alias, ""; got != want {
t.Errorf("subs[1].Alias = %q, want %q (本地表未收录时留空)", got, want)
}
}
func TestSubscribeRegionUsesHomeRegion(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
regionSubs: []oci.RegionSubscription{
{Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true},
{Key: "AMS", Name: "eu-amsterdam-1", Status: "IN_PROGRESS"},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
subs, err := svc.SubscribeRegion(context.Background(), cfg.ID, " ams ")
if err != nil {
t.Fatalf("SubscribeRegion: %v", err)
}
if got, want := client.subscribedKey, "AMS"; got != want {
t.Errorf("subscribed key = %q, want %q (应去空格并大写)", got, want)
}
if got, want := client.subscribedHomeRegion, "eu-frankfurt-1"; got != want {
t.Errorf("home region = %q, want %q (应由 HomeRegionKey 映射)", got, want)
}
if len(subs) != 2 {
t.Errorf("len(subs) = %d, want 2 (订阅后返回最新列表)", len(subs))
}
}
func TestSubscribeRegionRejectsEmptyKey(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if _, err := svc.SubscribeRegion(context.Background(), cfg.ID, " "); err == nil {
t.Error("SubscribeRegion(empty): got nil error, want failure")
}
}
func TestSubscribeRegionPropagatesError(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
subscribeErr: errors.New("NotAuthorizedOrNotFound"),
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
if _, err := svc.SubscribeRegion(context.Background(), cfg.ID, "AMS"); err == nil {
t.Error("SubscribeRegion with cloud error: got nil error, want failure")
}
}
func TestLimitsPassesQuery(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
limitValues: []oci.LimitValue{
{Name: "standard-a1-core-count", ScopeType: "AD", AvailabilityDomain: "AD-1", Value: 6},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
q := oci.LimitsQuery{Region: "eu-frankfurt-1", Service: "compute", Name: "standard-a1-core-count", WithAvailability: true}
values, err := svc.Limits(context.Background(), cfg.ID, q)
if err != nil {
t.Fatalf("Limits: %v", err)
}
if len(values) != 1 {
t.Fatalf("len(values) = %d, want 1", len(values))
}
if got, want := client.limitsQuery, q; got != want {
t.Errorf("query passed to client = %+v, want %+v", got, want)
}
}
func TestLimitsRequiresService(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if _, err := svc.Limits(context.Background(), cfg.ID, oci.LimitsQuery{}); err == nil {
t.Error("Limits without service: got nil error, want failure")
}
}
+212
View File
@@ -0,0 +1,212 @@
package service
import (
"context"
"fmt"
"gorm.io/gorm"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// syncScopeCaches 按配置开关同步区域 / 区间缓存:开启则整组刷新,关闭则清空。
// 随测活(导入 / 修改 / 测活按钮 / 定时任务)触发;OCI 调用失败不影响测活主流程。
func (s *OciConfigService) syncScopeCaches(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
if cfg.MultiRegion {
if subs, err := s.client.ListRegionSubscriptions(ctx, cred); err == nil {
_ = s.saveRegionCache(ctx, cfg.ID, subs)
}
} else {
s.db.WithContext(ctx).Where("oci_config_id = ?", cfg.ID).Delete(&model.RegionCache{})
}
if cfg.MultiCompartment {
if comps, err := s.client.ListCompartments(ctx, cred); err == nil {
_ = s.saveCompartmentCache(ctx, cfg.ID, comps)
}
} else {
s.db.WithContext(ctx).Where("oci_config_id = ?", cfg.ID).Delete(&model.CompartmentCache{})
}
}
// saveRegionCache 整组覆盖写入订阅区域缓存。
func (s *OciConfigService) saveRegionCache(ctx context.Context, cfgID uint, subs []oci.RegionSubscription) error {
rows := make([]model.RegionCache, 0, len(subs))
for _, sub := range subs {
rows = append(rows, model.RegionCache{
OciConfigID: cfgID, Key: sub.Key, Name: sub.Name,
Status: sub.Status, IsHomeRegion: sub.IsHomeRegion,
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.RegionCache{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// saveCompartmentCache 整组覆盖写入 compartment 缓存。
func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint, comps []oci.Compartment) error {
rows := make([]model.CompartmentCache, 0, len(comps))
for _, c := range comps {
rows = append(rows, model.CompartmentCache{
OciConfigID: cfgID, OCID: c.ID, Name: c.Name, State: c.LifecycleState,
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.CompartmentCache{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// CachedRegions 返回筛选器用的区域列表:未开启多区域支持时锁定为配置默认区域;
// 缓存缺失或存在非 READY 状态(订阅进行中)时实时刷新,直到全部 READY。
func (s *OciConfigService) CachedRegions(ctx context.Context, id uint) ([]RegionSubscriptionView, error) {
cfg, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if !cfg.MultiRegion {
return defaultOnlyRegion(cfg), nil
}
var rows []model.RegionCache
if err := s.db.WithContext(ctx).Where("oci_config_id = ?", id).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("load region cache: %w", err)
}
if len(rows) == 0 || hasPendingRegion(rows) {
if fresh, err := s.refreshRegionCache(ctx, cfg); err == nil {
return fresh, nil
}
// 实时刷新失败时退回现有缓存,避免筛选器整体不可用
}
return regionCacheViews(rows), nil
}
// refreshRegionCache 实时拉取订阅区域并覆盖缓存。
func (s *OciConfigService) refreshRegionCache(ctx context.Context, cfg *model.OciConfig) ([]RegionSubscriptionView, error) {
cred, err := s.credentialsOf(cfg)
if err != nil {
return nil, err
}
subs, err := s.client.ListRegionSubscriptions(ctx, cred)
if err != nil {
return nil, err
}
if err := s.saveRegionCache(ctx, cfg.ID, subs); err != nil {
return nil, err
}
return enrichRegionAlias(subs), nil
}
// reconcileRegionCache 把 SDK 实时数据与缓存比较,有差异时覆盖缓存。
func (s *OciConfigService) reconcileRegionCache(ctx context.Context, cfgID uint, subs []oci.RegionSubscription) {
var rows []model.RegionCache
if err := s.db.WithContext(ctx).Where("oci_config_id = ?", cfgID).Find(&rows).Error; err != nil {
return
}
if regionCacheDiffers(rows, subs) {
_ = s.saveRegionCache(ctx, cfgID, subs)
}
}
func regionCacheDiffers(rows []model.RegionCache, subs []oci.RegionSubscription) bool {
if len(rows) != len(subs) {
return true
}
byKey := make(map[string]model.RegionCache, len(rows))
for _, r := range rows {
byKey[r.Key] = r
}
for _, sub := range subs {
r, ok := byKey[sub.Key]
if !ok || r.Status != sub.Status || r.Name != sub.Name || r.IsHomeRegion != sub.IsHomeRegion {
return true
}
}
return false
}
func hasPendingRegion(rows []model.RegionCache) bool {
for _, r := range rows {
if r.Status != regionStatusReady {
return true
}
}
return false
}
// regionStatusReady 是 OCI 区域订阅完成态。
const regionStatusReady = "READY"
// defaultOnlyRegion 为未开启多区域支持的配置合成单元素列表(配置默认区域)。
func defaultOnlyRegion(cfg *model.OciConfig) []RegionSubscriptionView {
sub := oci.RegionSubscription{
Key: cfg.HomeRegionKey, Name: cfg.Region, Status: regionStatusReady,
}
if info, ok := oci.RegionByKey(cfg.HomeRegionKey); ok {
sub.IsHomeRegion = info.Name == cfg.Region
}
return enrichRegionAlias([]oci.RegionSubscription{sub})
}
func regionCacheViews(rows []model.RegionCache) []RegionSubscriptionView {
subs := make([]oci.RegionSubscription, 0, len(rows))
for _, r := range rows {
subs = append(subs, oci.RegionSubscription{
Key: r.Key, Name: r.Name, Status: r.Status, IsHomeRegion: r.IsHomeRegion,
})
}
// 与实时接口一致:主区域置顶,其余按 alias 字典序
oci.SortRegionSubscriptions(subs)
return enrichRegionAlias(subs)
}
// CachedCompartments 返回筛选器用的区间列表:未开启多区间支持返回空(前端锁定根);
// 已开启但缓存为空时实时拉取一次入库。
func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]oci.Compartment, error) {
cfg, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if !cfg.MultiCompartment {
return []oci.Compartment{}, nil
}
var rows []model.CompartmentCache
if err := s.db.WithContext(ctx).Where("oci_config_id = ?", id).
Order("name").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("load compartment cache: %w", err)
}
if len(rows) == 0 {
return s.refreshCompartmentCache(ctx, cfg)
}
comps := make([]oci.Compartment, 0, len(rows))
for _, r := range rows {
comps = append(comps, oci.Compartment{ID: r.OCID, Name: r.Name, LifecycleState: r.State})
}
return comps, nil
}
// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。
func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) {
cred, err := s.credentialsOf(cfg)
if err != nil {
return nil, err
}
comps, err := s.client.ListCompartments(ctx, cred)
if err != nil {
return nil, err
}
if err := s.saveCompartmentCache(ctx, cfg.ID, comps); err != nil {
return nil, err
}
return comps, nil
}
+200
View File
@@ -0,0 +1,200 @@
package service
import (
"context"
"testing"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
func multiScopeClient() *fakeClient {
return &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
regionSubs: []oci.RegionSubscription{
{Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true},
{Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"},
},
compartments: []oci.Compartment{
{ID: "ocid1.compartment..a", Name: "instances", LifecycleState: "ACTIVE"},
},
}
}
func importMultiConfig(t *testing.T, svc *OciConfigService) *model.OciConfig {
t.Helper()
in := trialImportInput()
in.MultiRegion = true
in.MultiCompartment = true
cfg, err := svc.Import(context.Background(), in)
if err != nil {
t.Fatalf("Import: %v", err)
}
return cfg
}
func TestImportSyncsScopeCaches(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importMultiConfig(t, svc)
if !cfg.MultiRegion || !cfg.MultiCompartment {
t.Fatalf("cfg switches = %v/%v, want true/true", cfg.MultiRegion, cfg.MultiCompartment)
}
var regions []model.RegionCache
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&regions)
if len(regions) != 2 {
t.Errorf("region cache rows = %d, want 2", len(regions))
}
var comps []model.CompartmentCache
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&comps)
if len(comps) != 1 || comps[0].OCID != "ocid1.compartment..a" {
t.Errorf("compartment cache = %+v, want 1 row ocid1.compartment..a", comps)
}
}
func TestImportWithoutSwitchesSkipsCaches(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
var count int64
svc.db.Model(&model.RegionCache{}).Where("oci_config_id = ?", cfg.ID).Count(&count)
if count != 0 {
t.Errorf("region cache rows = %d, want 0(未开启不入库)", count)
}
}
func TestCachedRegions(t *testing.T) {
tests := []struct {
name string
prepare func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient)
wantNames []string
wantCalls int // 缓存读取阶段期望的 SDK 调用次数
}{
{
name: "全 READY 直接用缓存",
prepare: func(*OciConfigService, *model.OciConfig, *fakeClient) {},
wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"},
wantCalls: 0,
},
{
name: "存在非 READY 强制实时刷新",
prepare: func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient) {
svc.db.Model(&model.RegionCache{}).
Where("oci_config_id = ? AND key = ?", cfg.ID, "AMS").
Update("status", "IN_PROGRESS")
client.regionSubs[1].Status = "READY" // SDK 已完成
},
wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"},
wantCalls: 1,
},
{
name: "缓存为空实时拉取入库",
prepare: func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient) {
svc.db.Where("oci_config_id = ?", cfg.ID).Delete(&model.RegionCache{})
},
wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"},
wantCalls: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importMultiConfig(t, svc)
tt.prepare(svc, cfg, client)
client.regionSubsCalls = 0
views, err := svc.CachedRegions(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("CachedRegions: %v", err)
}
got := make([]string, 0, len(views))
for _, v := range views {
got = append(got, v.Name)
}
if len(got) != len(tt.wantNames) {
t.Fatalf("regions = %v, want %v", got, tt.wantNames)
}
if client.regionSubsCalls != tt.wantCalls {
t.Errorf("SDK calls = %d, want %d", client.regionSubsCalls, tt.wantCalls)
}
})
}
}
func TestCachedRegionsWithoutMultiRegion(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
client.regionSubsCalls = 0
views, err := svc.CachedRegions(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("CachedRegions: %v", err)
}
if len(views) != 1 || views[0].Name != "eu-frankfurt-1" || !views[0].IsHomeRegion {
t.Errorf("views = %+v, want 仅默认区域且标记主区域", views)
}
if client.regionSubsCalls != 0 {
t.Errorf("SDK calls = %d, want 0(未开启不实时请求)", client.regionSubsCalls)
}
}
func TestCachedCompartments(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
multiCfg := importMultiConfig(t, svc)
comps, err := svc.CachedCompartments(context.Background(), multiCfg.ID)
if err != nil {
t.Fatalf("CachedCompartments: %v", err)
}
if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" {
t.Errorf("comps = %+v, want 缓存中的 1 项", comps)
}
in := trialImportInput()
in.Alias = "未开启"
plainCfg, err := svc.Import(context.Background(), in)
if err != nil {
t.Fatalf("Import: %v", err)
}
comps, err = svc.CachedCompartments(context.Background(), plainCfg.ID)
if err != nil {
t.Fatalf("CachedCompartments: %v", err)
}
if len(comps) != 0 {
t.Errorf("comps = %+v, want 空(未开启多区间)", comps)
}
}
func TestRegionSubscriptionsReconcilesCache(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importMultiConfig(t, svc)
// SDK 侧新订阅一个区域,订阅 Tab 实时读取后缓存应跟进
client.regionSubs = append(client.regionSubs,
oci.RegionSubscription{Key: "ICN", Name: "ap-seoul-1", Status: "IN_PROGRESS"})
if _, err := svc.RegionSubscriptions(context.Background(), cfg.ID); err != nil {
t.Fatalf("RegionSubscriptions: %v", err)
}
var rows []model.RegionCache
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&rows)
if len(rows) != 3 {
t.Fatalf("cache rows = %d, want 3diff 后跟进)", len(rows))
}
// 关闭开关的配置不应回写缓存
off := false
if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{MultiRegion: &off}); err != nil {
t.Fatalf("Update: %v", err)
}
var count int64
svc.db.Model(&model.RegionCache{}).Where("oci_config_id = ?", cfg.ID).Count(&count)
if count != 0 {
t.Errorf("cache rows = %d, want 0(关闭开关清空缓存)", count)
}
}
+190
View File
@@ -0,0 +1,190 @@
package service
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。
const (
settingSecLoginFailLimit = "security_login_fail_limit"
settingSecLoginLockMin = "security_login_lock_minutes"
settingSecIPRateRPS = "security_ip_rate_rps"
settingSecIPRateBurst = "security_ip_rate_burst"
settingSecRealIPHeader = "security_real_ip_header"
settingSecAppURL = "security_app_url"
// 密码登录禁用开关("1" 为禁用);启停校验在 AuthService.SetPasswordLoginDisabled
settingSecPasswordLoginOff = "security_password_login_off"
)
// SecuritySettings 是安全设置的读写视图;零值无意义,一律经 Security/SecurityCached 构造。
type SecuritySettings struct {
// 登录守卫:LockMinutes 分钟窗口内失败 FailLimit 次锁 LockMinutes 分钟
LoginFailLimit int `json:"loginFailLimit"`
LoginLockMinutes int `json:"loginLockMinutes"`
// 全局 IP 限速令牌桶
IPRateRPS int `json:"ipRateRps"`
IPRateBurst int `json:"ipRateBurst"`
// 真实IP请求头:空 = 直连(RemoteAddr);反代后必须选择与反代匹配的头
RealIPHeader string `json:"realIpHeader"`
// 面板公网基址;优先于 PUBLIC_URL 环境变量,回传链路与 OAuth 回调用
AppURL string `json:"appUrl"`
}
// securityDefaults 与探索文档主题三的推荐参数一致。
var securityDefaults = SecuritySettings{
LoginFailLimit: 5,
LoginLockMinutes: 15,
IPRateRPS: 10,
IPRateBurst: 30,
RealIPHeader: "",
AppURL: "",
}
// realIPHeaderPattern 校验真实IP请求头名:空为直连,非空仅限字母数字与连字符
// (RFC 7230 token 的常用子集),支持 X-Forwarded-For 等预设外的自定义头。
var realIPHeaderPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`)
func realIPHeaderValid(h string) bool {
return h == "" || realIPHeaderPattern.MatchString(h)
}
// ErrInvalidSecurity 标记安全设置越界或非法,api 层据此返回 400。
var ErrInvalidSecurity = fmt.Errorf("安全设置非法")
// validateSecurity 校验范围;AppURL 规范化(去尾斜杠)。
func validateSecurity(in *SecuritySettings) error {
switch {
case in.LoginFailLimit < 1 || in.LoginFailLimit > 20:
return fmt.Errorf("登录失败阈值须在 1-20 之间: %w", ErrInvalidSecurity)
case in.LoginLockMinutes < 1 || in.LoginLockMinutes > 1440:
return fmt.Errorf("锁定时长须在 1-1440 分钟之间: %w", ErrInvalidSecurity)
case in.IPRateRPS < 1 || in.IPRateRPS > 100:
return fmt.Errorf("IP 限速须在 1-100 req/s 之间: %w", ErrInvalidSecurity)
case in.IPRateBurst < 1 || in.IPRateBurst > 500:
return fmt.Errorf("突发额度须在 1-500 之间: %w", ErrInvalidSecurity)
case !realIPHeaderValid(in.RealIPHeader):
return fmt.Errorf("真实IP请求头仅限字母数字与连字符: %w", ErrInvalidSecurity)
}
in.AppURL = strings.TrimRight(strings.TrimSpace(in.AppURL), "/")
if in.AppURL != "" && !strings.HasPrefix(in.AppURL, "http://") && !strings.HasPrefix(in.AppURL, "https://") {
return fmt.Errorf("面板地址须以 http(s):// 开头: %w", ErrInvalidSecurity)
}
return nil
}
// Security 从库读安全设置;键缺省或脏数据按字段回退默认值。
func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error) {
out := securityDefaults
ints := []struct {
key string
dst *int
lo int
hi int
}{
{settingSecLoginFailLimit, &out.LoginFailLimit, 1, 20},
{settingSecLoginLockMin, &out.LoginLockMinutes, 1, 1440},
{settingSecIPRateRPS, &out.IPRateRPS, 1, 100},
{settingSecIPRateBurst, &out.IPRateBurst, 1, 500},
}
for _, f := range ints {
val, err := s.get(ctx, f.key)
if err != nil {
return SecuritySettings{}, err
}
if n, err := strconv.Atoi(val); err == nil && n >= f.lo && n <= f.hi {
*f.dst = n
}
}
header, err := s.get(ctx, settingSecRealIPHeader)
if err != nil {
return SecuritySettings{}, err
}
if realIPHeaderValid(header) {
out.RealIPHeader = header
}
if out.AppURL, err = s.get(ctx, settingSecAppURL); err != nil {
return SecuritySettings{}, err
}
return out, nil
}
// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。
func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error {
if err := validateSecurity(&in); err != nil {
return err
}
kv := map[string]string{
settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit),
settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes),
settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS),
settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst),
settingSecRealIPHeader: in.RealIPHeader,
settingSecAppURL: in.AppURL,
}
for key, value := range kv {
if err := s.set(ctx, key, value); err != nil {
return err
}
}
s.security.Store(in)
return nil
}
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
func (s *SettingService) ReloadSecurity(ctx context.Context) error {
sec, err := s.Security(ctx)
if err != nil {
return err
}
s.security.Store(sec)
return nil
}
// SecurityCached 返回安全设置内存快照,供每请求热路径零 IO 读取;
// 快照未初始化(启动未 Reload 或测试环境)时返回默认值。
func (s *SettingService) SecurityCached() SecuritySettings {
if v, ok := s.security.Load().(SecuritySettings); ok {
return v
}
return securityDefaults
}
// securityOf 是 nil 容忍的快照读取,供未注入 settings 的调用方(测试)回退默认。
func securityOf(s *SettingService) SecuritySettings {
if s == nil {
return securityDefaults
}
return s.SecurityCached()
}
// SetEnvPublicURL 记录 PUBLIC_URL 环境变量,作为 app_url 未配置时的回退。
func (s *SettingService) SetEnvPublicURL(url string) {
s.envPublicURL = strings.TrimRight(url, "/")
}
// PasswordLoginDisabled 读密码登录禁用开关;键缺省视为未禁用。
func (s *SettingService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
v, err := s.get(ctx, settingSecPasswordLoginOff)
return v == "1", err
}
// SetPasswordLoginDisabled 保存密码登录禁用开关。
func (s *SettingService) SetPasswordLoginDisabled(ctx context.Context, disabled bool) error {
v := ""
if disabled {
v = "1"
}
return s.set(ctx, settingSecPasswordLoginOff, v)
}
// EffectiveAppURL 返回生效的面板公网基址:设置里的 app_url 优先,回退环境变量。
func (s *SettingService) EffectiveAppURL() string {
if u := s.SecurityCached().AppURL; u != "" {
return u
}
return s.envPublicURL
}
+121
View File
@@ -0,0 +1,121 @@
package service
import (
"context"
"errors"
"testing"
)
func TestSecurityReadWrite(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, svc *SettingService)
want SecuritySettings
}{
{
name: "键全部缺省回退默认",
setup: func(t *testing.T, svc *SettingService) {},
want: securityDefaults,
},
{
name: "写入后读回",
setup: func(t *testing.T, svc *SettingService) {
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
},
want: SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com"},
},
{
name: "脏数据按字段回退默认",
setup: func(t *testing.T, svc *SettingService) {
for k, v := range map[string]string{
settingSecLoginFailLimit: "abc",
settingSecIPRateRPS: "9999",
settingSecRealIPHeader: "X Evil Header\r\n",
} {
if err := svc.set(context.Background(), k, v); err != nil {
t.Fatalf("set: %v", err)
}
}
},
want: securityDefaults,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
tt.setup(t, svc)
got, err := svc.Security(context.Background())
if err != nil {
t.Fatalf("Security: %v", err)
}
if got != tt.want {
t.Errorf("settings = %+v, want %+v", got, tt.want)
}
})
}
}
func TestUpdateSecurityRejectsInvalid(t *testing.T) {
base := securityDefaults
tests := []struct {
name string
mutate func(*SecuritySettings)
}{
{name: "失败阈值越界", mutate: func(s *SecuritySettings) { s.LoginFailLimit = 0 }},
{name: "锁定时长越界", mutate: func(s *SecuritySettings) { s.LoginLockMinutes = 1441 }},
{name: "限速越界", mutate: func(s *SecuritySettings) { s.IPRateRPS = 101 }},
{name: "突发越界", mutate: func(s *SecuritySettings) { s.IPRateBurst = 0 }},
{name: "请求头含非法字符", mutate: func(s *SecuritySettings) { s.RealIPHeader = "X Custom;" }},
{name: "面板地址缺协议", mutate: func(s *SecuritySettings) { s.AppURL = "demo.example.com" }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
in := base
tt.mutate(&in)
if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) {
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
}
})
}
}
func TestSecurityCachedSnapshot(t *testing.T) {
svc, _ := newSettingEnv(t)
// 未初始化时返回默认,不触库
if got := svc.SecurityCached(); got != securityDefaults {
t.Errorf("cold cache = %+v, want defaults", got)
}
in := securityDefaults
in.LoginFailLimit = 8
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
// Update 即刷新快照
if got := svc.SecurityCached(); got.LoginFailLimit != 8 {
t.Errorf("cached limit = %d, want 8", got.LoginFailLimit)
}
// nil 容忍
if got := securityOf(nil); got != securityDefaults {
t.Errorf("securityOf(nil) = %+v, want defaults", got)
}
}
func TestEffectiveAppURL(t *testing.T) {
svc, _ := newSettingEnv(t)
svc.SetEnvPublicURL("https://env.example.com/")
if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
t.Errorf("env fallback = %q", got)
}
in := securityDefaults
in.AppURL = "https://app.example.com"
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
t.Errorf("app_url 应优先, got %q", got)
}
}
+346
View File
@@ -0,0 +1,346 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync/atomic"
"time"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// Telegram 通知相关的配置键。
const (
settingTelegramEnabled = "telegram_enabled" // "1" / "0"
settingTelegramBotToken = "telegram_bot_token" // AES-GCM 密文
settingTelegramChatID = "telegram_chat_id" // 明文
)
// 通知事件开关的配置键(notify_event_{kind}),值 "1"/"0"
// 键缺省(存量部署从未保存过)视为开启,只有显式 "0" 视为关闭。
// 云端事件按类别细分(旧总开关键 notify_event_log_event 已废弃,库中遗留无害)。
const (
settingNotifyEventTaskFail = "notify_event_task_fail"
settingNotifyEventTaskRecover = "notify_event_task_recover"
settingNotifyEventSnatchSuccess = "notify_event_snatch_success"
settingNotifyEventTenantDead = "notify_event_tenant_dead"
settingNotifyEventTaskStop = "notify_event_task_stop"
settingNotifyEventLoginLock = "notify_event_login_lock"
settingNotifyEventModelDeprecated = "notify_event_model_deprecated"
settingNotifyEventLogEventInstance = "notify_event_log_event_instance"
settingNotifyEventLogEventIdentity = "notify_event_log_event_identity"
settingNotifyEventLogEventPolicy = "notify_event_log_event_policy"
settingNotifyEventLogEventRegion = "notify_event_log_event_region"
settingNotifyEventLogEventLogin = "notify_event_log_event_login"
)
// 任务行为相关的配置键与取值约束(抢机连续 NotAuthenticated 熔断阈值)。
const (
settingSnatchAuthFailLimit = "snatch_auth_fail_limit"
defaultSnatchAuthFailLimit = 3
minSnatchAuthFailLimit = 1
maxSnatchAuthFailLimit = 10
)
// ErrInvalidAuthFailLimit 标记抢机熔断阈值越界,api 层据此返回 400。
var ErrInvalidAuthFailLimit = fmt.Errorf("熔断阈值须在 %d-%d 之间", minSnatchAuthFailLimit, maxSnatchAuthFailLimit)
// SettingService 读写系统级键值配置,敏感值经 Cipher 加密落库;
// 安全设置(WAF/真实IP头/app_url)另持内存快照供每请求热路径零 IO 读取。
type SettingService struct {
db *gorm.DB
cipher *crypto.Cipher
security atomic.Value // SecuritySettings 快照,ReloadSecurity/UpdateSecurity 刷新
envPublicURL string // PUBLIC_URL 环境变量,app_url 未配置时的回退
}
// NewSettingService 组装依赖。
func NewSettingService(db *gorm.DB, cipher *crypto.Cipher) *SettingService {
return &SettingService{db: db, cipher: cipher}
}
// get 读取键值;键不存在视为未配置,返回空串。
func (s *SettingService) get(ctx context.Context, key string) (string, error) {
var st model.Setting
err := s.db.WithContext(ctx).First(&st, "key = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get setting %s: %w", key, err)
}
return st.Value, nil
}
// set 写入键值(key 为主键,Save 即 upsert)。
func (s *SettingService) set(ctx context.Context, key, value string) error {
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
return fmt.Errorf("set setting %s: %w", key, err)
}
return nil
}
// getMany 批量读取多个键;任一失败即返回错误。
func (s *SettingService) getMany(ctx context.Context, keys ...string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
value, err := s.get(ctx, key)
if err != nil {
return nil, err
}
out[key] = value
}
return out, nil
}
// TelegramConfig 是通知发送所需的完整配置,token 已解密,仅供服务内部消费。
type TelegramConfig struct {
Enabled bool
BotToken string
ChatID string
}
// TelegramConfig 返回解密后的 Telegram 配置。
func (s *SettingService) TelegramConfig(ctx context.Context) (TelegramConfig, error) {
enabled, err := s.get(ctx, settingTelegramEnabled)
if err != nil {
return TelegramConfig{}, err
}
chatID, err := s.get(ctx, settingTelegramChatID)
if err != nil {
return TelegramConfig{}, err
}
token, err := s.telegramToken(ctx)
if err != nil {
return TelegramConfig{}, err
}
return TelegramConfig{Enabled: enabled == "1", BotToken: token, ChatID: chatID}, nil
}
// telegramToken 读取并解密 bot token;未配置时返回空串。
func (s *SettingService) telegramToken(ctx context.Context) (string, error) {
enc, err := s.get(ctx, settingTelegramBotToken)
if err != nil || enc == "" {
return "", err
}
token, err := s.cipher.DecryptString(enc)
if err != nil {
return "", fmt.Errorf("decrypt telegram token: %w", err)
}
return token, nil
}
// TelegramView 是设置接口的响应视图,绝不包含 token 明文。
type TelegramView struct {
Enabled bool `json:"enabled"`
ChatID string `json:"chatId"`
TokenSet bool `json:"tokenSet"`
TokenTail string `json:"tokenTail"`
}
// TelegramView 返回脱敏后的 Telegram 配置视图。
func (s *SettingService) TelegramView(ctx context.Context) (TelegramView, error) {
cfg, err := s.TelegramConfig(ctx)
if err != nil {
return TelegramView{}, err
}
return TelegramView{
Enabled: cfg.Enabled,
ChatID: cfg.ChatID,
TokenSet: cfg.BotToken != "",
TokenTail: tokenTail(cfg.BotToken),
}, nil
}
// tokenTail 取 token 尾 4 位用于展示;过短时不展示,避免变相泄露。
func tokenTail(token string) string {
if len(token) < 8 {
return ""
}
return token[len(token)-4:]
}
// UpdateTelegramInput 是更新 Telegram 配置的输入;
// BotToken 为 nil 沿用已存 token,非 nil 时覆盖(空串表示清除)。
type UpdateTelegramInput struct {
Enabled bool
BotToken *string
ChatID string
}
// UpdateTelegram 保存 Telegram 配置,token 加密落库。
func (s *SettingService) UpdateTelegram(ctx context.Context, in UpdateTelegramInput) error {
enabled := "0"
if in.Enabled {
enabled = "1"
}
if err := s.set(ctx, settingTelegramEnabled, enabled); err != nil {
return err
}
if err := s.set(ctx, settingTelegramChatID, in.ChatID); err != nil {
return err
}
if in.BotToken == nil {
return nil
}
return s.saveTelegramToken(ctx, *in.BotToken)
}
// saveTelegramToken 加密保存 token;空串直接落空值表示清除。
func (s *SettingService) saveTelegramToken(ctx context.Context, token string) error {
if token == "" {
return s.set(ctx, settingTelegramBotToken, "")
}
enc, err := s.cipher.EncryptString(token)
if err != nil {
return fmt.Errorf("encrypt telegram token: %w", err)
}
return s.set(ctx, settingTelegramBotToken, enc)
}
// NotifyEventsView 是通知事件开关视图;零值无意义,一律由 NotifyEvents 构造。
// 云端事件按类别细分:实例生命周期 / 用户与凭据 / 策略 / 区域订阅 / 控制台登录。
type NotifyEventsView struct {
TaskFail bool `json:"taskFail"`
TaskRecover bool `json:"taskRecover"`
SnatchSuccess bool `json:"snatchSuccess"`
TenantDead bool `json:"tenantDead"`
TaskStop bool `json:"taskStop"`
LoginLock bool `json:"loginLock"`
ModelDeprecated bool `json:"modelDeprecated"`
LogEventInstance bool `json:"logEventInstance"`
LogEventIdentity bool `json:"logEventIdentity"`
LogEventPolicy bool `json:"logEventPolicy"`
LogEventRegion bool `json:"logEventRegion"`
LogEventLogin bool `json:"logEventLogin"`
}
// notifyEventFields 把视图字段与配置键一一对应,供读写复用。
func notifyEventFields(v *NotifyEventsView) []struct {
key string
on *bool
} {
return []struct {
key string
on *bool
}{
{settingNotifyEventTaskFail, &v.TaskFail},
{settingNotifyEventTaskRecover, &v.TaskRecover},
{settingNotifyEventSnatchSuccess, &v.SnatchSuccess},
{settingNotifyEventTenantDead, &v.TenantDead},
{settingNotifyEventTaskStop, &v.TaskStop},
{settingNotifyEventLoginLock, &v.LoginLock},
{settingNotifyEventModelDeprecated, &v.ModelDeprecated},
{settingNotifyEventLogEventInstance, &v.LogEventInstance},
{settingNotifyEventLogEventIdentity, &v.LogEventIdentity},
{settingNotifyEventLogEventPolicy, &v.LogEventPolicy},
{settingNotifyEventLogEventRegion, &v.LogEventRegion},
{settingNotifyEventLogEventLogin, &v.LogEventLogin},
}
}
// NotifyEvents 返回全部通知事件开关;键缺省视为开启,兼容存量部署。
func (s *SettingService) NotifyEvents(ctx context.Context) (NotifyEventsView, error) {
var view NotifyEventsView
for _, f := range notifyEventFields(&view) {
val, err := s.get(ctx, f.key)
if err != nil {
return NotifyEventsView{}, err
}
*f.on = val != "0"
}
return view, nil
}
// UpdateNotifyEvents 全量保存五个通知事件开关。
func (s *SettingService) UpdateNotifyEvents(ctx context.Context, in NotifyEventsView) error {
for _, f := range notifyEventFields(&in) {
value := "0"
if *f.on {
value = "1"
}
if err := s.set(ctx, f.key, value); err != nil {
return err
}
}
return nil
}
// NotifyEventEnabled 查询单个通知事件开关,供发送前过滤;
// 只有显式 "0" 视为关闭,键缺省或读取失败一律按开启返回(降级不漏发),错误仅记日志。
func (s *SettingService) NotifyEventEnabled(ctx context.Context, kind string) bool {
val, err := s.get(ctx, "notify_event_"+kind)
if err != nil {
log.Printf("notify event %s switch: %v", kind, err)
return true
}
return val != "0"
}
// NotifyTemplate 读取 kind 的自定义模板;空串表示未自定义(按默认模板发送)。
func (s *SettingService) NotifyTemplate(ctx context.Context, kind string) (string, error) {
return s.get(ctx, notifyTplKey(kind))
}
// NotifyTemplates 返回全部通知模板视图(默认模板 + 自定义现值),顺序稳定。
func (s *SettingService) NotifyTemplates(ctx context.Context) ([]NotifyTemplateView, error) {
out := make([]NotifyTemplateView, 0, len(notifyTplOrder))
for _, kind := range notifyTplOrder {
def := notifyTplDefs[kind]
custom, err := s.get(ctx, notifyTplKey(kind))
if err != nil {
return nil, err
}
out = append(out, NotifyTemplateView{
Kind: kind, Label: def.Label, Vars: def.Vars,
DefaultTemplate: def.Default, Template: custom,
})
}
return out, nil
}
// UpdateNotifyTemplate 保存 kind 的自定义模板;空串清除自定义(恢复默认)。
func (s *SettingService) UpdateNotifyTemplate(ctx context.Context, kind, tpl string) error {
if !notifyTplValid(kind) {
return fmt.Errorf("未知通知类型 %q", kind)
}
tpl = strings.TrimSpace(tpl)
if len([]rune(tpl)) > 2000 {
return fmt.Errorf("模板长度不能超过 2000 字符")
}
return s.set(ctx, notifyTplKey(kind), tpl)
}
// TaskSettingsView 是任务行为设置的读写视图。
type TaskSettingsView struct {
SnatchAuthFailLimit int `json:"snatchAuthFailLimit"`
}
// TaskSettings 返回任务行为设置;阈值缺省或非法(非整数 / 越界脏数据)时回退默认值。
func (s *SettingService) TaskSettings(ctx context.Context) (TaskSettingsView, error) {
val, err := s.get(ctx, settingSnatchAuthFailLimit)
if err != nil {
return TaskSettingsView{}, err
}
limit, err := strconv.Atoi(val)
if err != nil || limit < minSnatchAuthFailLimit || limit > maxSnatchAuthFailLimit {
limit = defaultSnatchAuthFailLimit
}
return TaskSettingsView{SnatchAuthFailLimit: limit}, nil
}
// UpdateTaskSettings 保存任务行为设置;阈值越界返回 ErrInvalidAuthFailLimit。
func (s *SettingService) UpdateTaskSettings(ctx context.Context, in TaskSettingsView) error {
if in.SnatchAuthFailLimit < minSnatchAuthFailLimit || in.SnatchAuthFailLimit > maxSnatchAuthFailLimit {
return fmt.Errorf("update task settings: %w", ErrInvalidAuthFailLimit)
}
return s.set(ctx, settingSnatchAuthFailLimit, strconv.Itoa(in.SnatchAuthFailLimit))
}
+298
View File
@@ -0,0 +1,298 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// newSettingEnv 建一个只含 Setting 表的内存库环境。
func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
t.Helper()
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: 库每个连接彼此独立,SendAsync 等后台 goroutine 读库需复用同一连接
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Setting{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
return NewSettingService(db, cipher), db
}
func strPtr(s string) *string { return &s }
func TestUpdateTelegramTokenHandling(t *testing.T) {
tests := []struct {
name string
initial *string // 先保存一次的 token;nil 表示未配置过
update *string // 第二次更新携带的 botToken
wantToken string // 最终 TelegramConfig 应解出的 token
}{
{name: "nil 沿用旧 token", initial: strPtr("tok-1234567890"), update: nil, wantToken: "tok-1234567890"},
{name: "非 nil 覆盖旧 token", initial: strPtr("tok-1234567890"), update: strPtr("tok-abcdefghij"), wantToken: "tok-abcdefghij"},
{name: "空串清除已存 token", initial: strPtr("tok-1234567890"), update: strPtr(""), wantToken: ""},
{name: "首次设置", initial: nil, update: strPtr("tok-first99999"), wantToken: "tok-first99999"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
ctx := context.Background()
if tt.initial != nil {
in := UpdateTelegramInput{Enabled: true, BotToken: tt.initial, ChatID: "42"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("save initial: %v", err)
}
}
in := UpdateTelegramInput{Enabled: true, BotToken: tt.update, ChatID: "42"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("update: %v", err)
}
cfg, err := svc.TelegramConfig(ctx)
if err != nil {
t.Fatalf("TelegramConfig: %v", err)
}
if cfg.BotToken != tt.wantToken {
t.Errorf("token = %q, want %q", cfg.BotToken, tt.wantToken)
}
})
}
}
func TestTelegramViewNeverReturnsPlaintext(t *testing.T) {
svc, db := newSettingEnv(t)
ctx := context.Background()
token := "123456789:AAtestTokenValue"
in := UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "-100200"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("UpdateTelegram: %v", err)
}
view, err := svc.TelegramView(ctx)
if err != nil {
t.Fatalf("TelegramView: %v", err)
}
want := TelegramView{Enabled: true, ChatID: "-100200", TokenSet: true, TokenTail: "alue"}
if view != want {
t.Errorf("view = %+v, want %+v", view, want)
}
// 落库必须是密文:存储值不含 token 原文,但能解密还原
var st model.Setting
if err := db.First(&st, "key = ?", settingTelegramBotToken).Error; err != nil {
t.Fatalf("load setting row: %v", err)
}
if strings.Contains(st.Value, token) {
t.Errorf("token stored in plaintext: %q", st.Value)
}
cfg, err := svc.TelegramConfig(ctx)
if err != nil || cfg.BotToken != token {
t.Errorf("decrypted token = %q (%v), want %q", cfg.BotToken, err, token)
}
}
func TestTokenTail(t *testing.T) {
tests := []struct {
name string
token string
want string
}{
{name: "常规 token 取尾 4 位", token: "123456789:AAtestTokenValue", want: "alue"},
{name: "过短不展示", token: "abc", want: ""},
{name: "空串", token: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tokenTail(tt.token); got != tt.want {
t.Errorf("tokenTail(%q) = %q, want %q", tt.token, got, tt.want)
}
})
}
}
func TestNotifyEventsReadWrite(t *testing.T) {
allOn := NotifyEventsView{
TaskFail: true, TaskRecover: true, SnatchSuccess: true, TenantDead: true,
TaskStop: true, LoginLock: true, ModelDeprecated: true,
LogEventInstance: true, LogEventIdentity: true, LogEventPolicy: true,
LogEventRegion: true, LogEventLogin: true,
}
offTaskFailStop := allOn
offTaskFailStop.TaskFail, offTaskFailStop.TaskStop = false, false
offTenantDead := allOn
offTenantDead.TenantDead = false
tests := []struct {
name string
setup func(t *testing.T, svc *SettingService)
want NotifyEventsView
}{
{
name: "键全部缺省视为全开(兼容存量部署)",
setup: func(t *testing.T, svc *SettingService) {},
want: allOn,
},
{
name: "写 0 后读回 false",
setup: func(t *testing.T, svc *SettingService) {
in := allOn
in.TaskFail = false
in.TaskStop = false
if err := svc.UpdateNotifyEvents(context.Background(), in); err != nil {
t.Fatalf("UpdateNotifyEvents: %v", err)
}
},
want: offTaskFailStop,
},
{
name: "部分键缺失时只关显式为 0 的项",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingNotifyEventTenantDead, "0"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: offTenantDead,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
tt.setup(t, svc)
got, err := svc.NotifyEvents(context.Background())
if err != nil {
t.Fatalf("NotifyEvents: %v", err)
}
if got != tt.want {
t.Errorf("view = %+v, want %+v", got, tt.want)
}
})
}
}
func TestNotifyEventEnabled(t *testing.T) {
tests := []struct {
name string
value *string // 键值;nil 表示键不存在
want bool
}{
{name: "键缺失视为开启", value: nil, want: true},
{name: "显式 0 关闭", value: strPtr("0"), want: false},
{name: "显式 1 开启", value: strPtr("1"), want: true},
{name: "脏数据非 0 视为开启", value: strPtr("maybe"), want: true},
{name: "空值视为开启", value: strPtr(""), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
ctx := context.Background()
if tt.value != nil {
if err := svc.set(ctx, settingNotifyEventTaskFail, *tt.value); err != nil {
t.Fatalf("set: %v", err)
}
}
if got := svc.NotifyEventEnabled(ctx, "task_fail"); got != tt.want {
t.Errorf("NotifyEventEnabled = %v, want %v", got, tt.want)
}
})
}
}
// saveTaskLimit 返回把抢机熔断阈值写入设置的 setup 步骤,供 TestTaskSettings 复用。
func saveTaskLimit(limit int) func(t *testing.T, svc *SettingService) {
return func(t *testing.T, svc *SettingService) {
t.Helper()
in := TaskSettingsView{SnatchAuthFailLimit: limit}
if err := svc.UpdateTaskSettings(context.Background(), in); err != nil {
t.Fatalf("UpdateTaskSettings(%d): %v", limit, err)
}
}
}
func TestTaskSettings(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, svc *SettingService)
want int
}{
{name: "键缺省回退默认 3", setup: func(t *testing.T, svc *SettingService) {}, want: 3},
{name: "写 5 读回 5", setup: saveTaskLimit(5), want: 5},
{name: "合法下界写 1 读回 1", setup: saveTaskLimit(1), want: 1},
{name: "合法上界写 10 读回 10", setup: saveTaskLimit(10), want: 10},
{
name: "脏数据非整数回退 3",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "abc"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: 3,
},
{
name: "脏数据越界回退 3",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "99"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
tt.setup(t, svc)
got, err := svc.TaskSettings(context.Background())
if err != nil {
t.Fatalf("TaskSettings: %v", err)
}
if got.SnatchAuthFailLimit != tt.want {
t.Errorf("limit = %d, want %d", got.SnatchAuthFailLimit, tt.want)
}
})
}
}
func TestUpdateTaskSettingsRejectsOutOfRange(t *testing.T) {
tests := []struct {
name string
limit int
}{
{name: "低于下界 0", limit: 0},
{name: "高于上界 11", limit: 11},
{name: "负值", limit: -3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
err := svc.UpdateTaskSettings(context.Background(), TaskSettingsView{SnatchAuthFailLimit: tt.limit})
if !errors.Is(err, ErrInvalidAuthFailLimit) {
t.Fatalf("err = %v, want ErrInvalidAuthFailLimit", err)
}
got, err := svc.TaskSettings(context.Background())
if err != nil {
t.Fatalf("TaskSettings: %v", err)
}
if got.SnatchAuthFailLimit != 3 {
t.Errorf("拒绝后读回 = %d, want 保持默认 3", got.SnatchAuthFailLimit)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package service
import (
"context"
"fmt"
"oci-portal/internal/oci"
)
// Subscriptions 列出租户全部订阅摘要。
func (s *OciConfigService) Subscriptions(ctx context.Context, id uint) ([]oci.SubscriptionInfo, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListSubscriptions(ctx, cred)
}
// SubscriptionDetail 查询租户单个订阅的完整信息。
func (s *OciConfigService) SubscriptionDetail(ctx context.Context, id uint, subscriptionID string) (oci.SubscriptionDetail, error) {
if subscriptionID == "" {
return oci.SubscriptionDetail{}, fmt.Errorf("subscription detail: subscription id is empty")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.SubscriptionDetail{}, err
}
return s.client.GetSubscription(ctx, cred, subscriptionID)
}
// LimitServices 列出配额体系支持的全部服务。
func (s *OciConfigService) LimitServices(ctx context.Context, id uint, region string) ([]oci.LimitService, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListLimitServices(ctx, cred, region)
}
// credentialsByID 加载配置快照并解密出完整凭据。
func (s *OciConfigService) credentialsByID(ctx context.Context, id uint) (oci.Credentials, error) {
cfg, err := s.Get(ctx, id)
if err != nil {
return oci.Credentials{}, err
}
return s.credentialsOf(cfg)
}
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"context"
"testing"
"oci-portal/internal/oci"
)
func TestSubscriptionsPassThrough(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
subscriptions: []oci.SubscriptionInfo{
{ID: "ocid1.organizationssubscription.oc1..a", ServiceName: "CLOUDCM"},
{ID: "ocid1.organizationssubscription.oc1..b", ServiceName: "SAAS"},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
subs, err := svc.Subscriptions(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("Subscriptions: %v", err)
}
if len(subs) != 2 {
t.Fatalf("len(subs) = %d, want 2", len(subs))
}
if got, want := subs[0].ServiceName, "CLOUDCM"; got != want {
t.Errorf("subs[0].ServiceName = %q, want %q", got, want)
}
}
func TestSubscriptionDetailPassesID(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
subDetail: oci.SubscriptionDetail{
ID: "ocid1.organizationssubscription.oc1..a",
ServiceName: "CLOUDCM",
PaymentModel: "FREE_TRIAL",
Promotions: []oci.PromotionInfo{{Status: "ACTIVE", Amount: 300}},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
detail, err := svc.SubscriptionDetail(context.Background(), cfg.ID, "ocid1.organizationssubscription.oc1..a")
if err != nil {
t.Fatalf("SubscriptionDetail: %v", err)
}
if got, want := client.subDetailID, "ocid1.organizationssubscription.oc1..a"; got != want {
t.Errorf("passed subscription id = %q, want %q", got, want)
}
if len(detail.Promotions) != 1 || detail.Promotions[0].Amount != 300 {
t.Errorf("Promotions = %+v, want one ACTIVE amount 300", detail.Promotions)
}
}
func TestSubscriptionDetailRejectsEmptyID(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg := importAliveConfig(t, svc)
if _, err := svc.SubscriptionDetail(context.Background(), cfg.ID, ""); err == nil {
t.Error("SubscriptionDetail(empty id): got nil error, want failure")
}
}
func TestLimitServicesPassThrough(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"},
limitServices: []oci.LimitService{
{Name: "compute", Description: "Compute"},
{Name: "vcn", Description: "Virtual Cloud Network"},
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
services, err := svc.LimitServices(context.Background(), cfg.ID, "")
if err != nil {
t.Fatalf("LimitServices: %v", err)
}
if len(services) != 2 {
t.Fatalf("len(services) = %d, want 2", len(services))
}
if got, want := services[0].Name, "compute"; got != want {
t.Errorf("services[0].Name = %q, want %q", got, want)
}
}
+158
View File
@@ -0,0 +1,158 @@
package service
import (
"context"
"fmt"
"log"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// 系统日志保留策略与异步写入超时。
const (
systemLogRetention = 90 * 24 * time.Hour // 过期删除阈值
systemLogMaxRows = 50000 // 总量兜底上限,超出删最旧
systemLogCleanupTick = 24 * time.Hour // 周期清理间隔
systemLogWriteWait = 3 * time.Second // 单条异步写入超时
)
// 系统日志查询分页默认值与上限。
const (
systemLogDefaultPageSize = 20
systemLogMaxPageSize = 100
)
// SystemLogService 记录并查询面板自身的操作日志(区别于 OCI 云端审计事件)。
type SystemLogService struct {
db *gorm.DB
wg sync.WaitGroup
}
// NewSystemLogService 组装依赖;调用 StartCleanup 后开始周期清理。
func NewSystemLogService(db *gorm.DB) *SystemLogService {
return &SystemLogService{db: db}
}
// Record 异步落一条日志,不阻塞业务请求;写入失败只记日志。
func (s *SystemLogService) Record(entry model.SystemLog) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), systemLogWriteWait)
defer cancel()
if err := s.record(ctx, entry); err != nil {
log.Printf("system log record: %v", err)
}
}()
}
// record 同步落库,供 Record 内部与测试直调。
func (s *SystemLogService) record(ctx context.Context, entry model.SystemLog) error {
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now()
}
if err := s.db.WithContext(ctx).Create(&entry).Error; err != nil {
return fmt.Errorf("create system log: %w", err)
}
return nil
}
// Wait 等待在途异步写入与清理 goroutine 退出,供进程收尾与测试同步。
func (s *SystemLogService) Wait() {
s.wg.Wait()
}
// SystemLogQuery 是日志查询参数;Keyword 对路径与用户名模糊匹配。
type SystemLogQuery struct {
Page int
PageSize int
Keyword string
}
// normalize 补分页默认值并钳制上限。
func (q SystemLogQuery) normalize() SystemLogQuery {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 {
q.PageSize = systemLogDefaultPageSize
}
if q.PageSize > systemLogMaxPageSize {
q.PageSize = systemLogMaxPageSize
}
return q
}
// List 按时间倒序分页查询日志,返回当前页与总数。
func (s *SystemLogService) List(ctx context.Context, q SystemLogQuery) ([]model.SystemLog, int64, error) {
q = q.normalize()
tx := s.db.WithContext(ctx).Model(&model.SystemLog{})
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
tx = tx.Where("path LIKE ? OR username LIKE ?", kw, kw)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count system logs: %w", err)
}
items := make([]model.SystemLog, 0, q.PageSize)
err := tx.Order("created_at DESC, id DESC").
Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).
Find(&items).Error
if err != nil {
return nil, 0, fmt.Errorf("list system logs: %w", err)
}
return items, total, nil
}
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
func (s *SystemLogService) StartCleanup(ctx context.Context) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.cleanupOnce(ctx)
ticker := time.NewTicker(systemLogCleanupTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupOnce(ctx)
}
}
}()
}
// cleanupOnce 执行一轮清理,失败只记日志、不中断周期调度。
func (s *SystemLogService) cleanupOnce(ctx context.Context) {
if err := s.cleanup(ctx, systemLogRetention, systemLogMaxRows); err != nil {
log.Printf("system log cleanup: %v", err)
}
}
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
func (s *SystemLogService) cleanup(ctx context.Context, retention time.Duration, maxRows int) error {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&model.SystemLog{}).Error; err != nil {
return fmt.Errorf("delete expired system logs: %w", err)
}
var total int64
if err := s.db.WithContext(ctx).Model(&model.SystemLog{}).Count(&total).Error; err != nil {
return fmt.Errorf("count system logs: %w", err)
}
overflow := int(total) - maxRows
if overflow <= 0 {
return nil
}
oldest := s.db.Model(&model.SystemLog{}).Select("id").
Order("created_at ASC, id ASC").Limit(overflow)
if err := s.db.WithContext(ctx).Where("id IN (?)", oldest).Delete(&model.SystemLog{}).Error; err != nil {
return fmt.Errorf("trim system logs over cap: %w", err)
}
return nil
}
+217
View File
@@ -0,0 +1,217 @@
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/model"
)
// newSystemLogEnv 建一个只含 SystemLog 表的内存库环境。
func newSystemLogEnv(t *testing.T) (*SystemLogService, *gorm.DB) {
t.Helper()
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: 库每个连接彼此独立,Record 的异步 goroutine 可能拿到新连接而丢表,锁单连接
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)
}
return NewSystemLogService(db), db
}
// seedLogs 由旧到新插入 n 条日志,时间逐分钟递增,路径 / 用户名带序号便于断言。
func seedLogs(t *testing.T, db *gorm.DB, n int) {
t.Helper()
base := time.Now().Add(-time.Duration(n) * time.Minute)
for i := 0; i < n; i++ {
row := model.SystemLog{
Username: fmt.Sprintf("user-%02d", i),
Method: "POST",
Path: fmt.Sprintf("/api/v1/res-%02d", i),
Status: 200,
CreatedAt: base.Add(time.Duration(i) * time.Minute),
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed log %d: %v", i, err)
}
}
}
func TestSystemLogList(t *testing.T) {
tests := []struct {
name string
q SystemLogQuery
wantLen int
wantTotal int64
wantFirstPath string // 空串表示不校验首条
}{
{name: "零值分页取默认 20 条最新在前", q: SystemLogQuery{}, wantLen: 20, wantTotal: 25, wantFirstPath: "/api/v1/res-24"},
{name: "第二页取余下 5 条", q: SystemLogQuery{Page: 2, PageSize: 20}, wantLen: 5, wantTotal: 25, wantFirstPath: "/api/v1/res-04"},
{name: "pageSize 超上限被钳制仍能取全量", q: SystemLogQuery{PageSize: 200}, wantLen: 25, wantTotal: 25},
{name: "关键字模糊匹配路径", q: SystemLogQuery{Keyword: "res-1"}, wantLen: 10, wantTotal: 10, wantFirstPath: "/api/v1/res-19"},
{name: "关键字模糊匹配用户名", q: SystemLogQuery{Keyword: "user-05"}, wantLen: 1, wantTotal: 1, wantFirstPath: "/api/v1/res-05"},
{name: "关键字无匹配返回空页", q: SystemLogQuery{Keyword: "no-such"}, wantLen: 0, wantTotal: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, db := newSystemLogEnv(t)
seedLogs(t, db, 25)
items, total, err := svc.List(context.Background(), tt.q)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != tt.wantLen || total != tt.wantTotal {
t.Errorf("got %d items total %d, want %d items total %d", len(items), total, tt.wantLen, tt.wantTotal)
}
if tt.wantFirstPath != "" && items[0].Path != tt.wantFirstPath {
t.Errorf("first path = %q, want %q", items[0].Path, tt.wantFirstPath)
}
})
}
}
func TestSystemLogListStableOrder(t *testing.T) {
svc, db := newSystemLogEnv(t)
// 同一时间戳的两条记录按 id 倒序,保证翻页顺序稳定
ts := time.Now().Truncate(time.Second)
for i := 0; i < 2; i++ {
row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: ts}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed: %v", err)
}
}
items, _, err := svc.List(context.Background(), SystemLogQuery{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 2 || items[0].ID <= items[1].ID {
t.Errorf("order = %+v, want id desc within same created_at", items)
}
}
func TestSystemLogRecord(t *testing.T) {
tests := []struct {
name string
async bool
}{
{name: "record 同步落库", async: false},
{name: "Record 异步落库", async: true},
}
entry := model.SystemLog{
Username: "admin", Method: "PUT", Path: "/api/v1/oci-configs/:id",
Status: 200, DurationMs: 18, ClientIP: "203.0.113.7",
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, db := newSystemLogEnv(t)
if tt.async {
svc.Record(entry)
svc.Wait()
} else if err := svc.record(context.Background(), entry); err != nil {
t.Fatalf("record: %v", err)
}
var got model.SystemLog
if err := db.First(&got).Error; err != nil {
t.Fatalf("load row: %v", err)
}
got.ID, got.CreatedAt = 0, entry.CreatedAt // 自动填充字段不参与对比
if got != entry {
t.Errorf("row = %+v, want %+v", got, entry)
}
})
}
}
func TestSystemLogRecordFillsCreatedAt(t *testing.T) {
svc, db := newSystemLogEnv(t)
if err := svc.record(context.Background(), model.SystemLog{Path: "/p"}); err != nil {
t.Fatalf("record: %v", err)
}
var got model.SystemLog
if err := db.First(&got).Error; err != nil {
t.Fatalf("load row: %v", err)
}
if got.CreatedAt.IsZero() {
t.Error("createdAt is zero, want auto filled")
}
}
func TestSystemLogCleanup(t *testing.T) {
tests := []struct {
name string
ages []time.Duration // 每条记录距今的时长,由旧到新
retention time.Duration
maxRows int
wantPaths []string // 清理后应保留的路径(按时间升序编号 p0..pN)
}{
{
name: "只删过期", ages: []time.Duration{100 * 24 * time.Hour, time.Hour},
retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p1"},
},
{
name: "超上限删最旧", ages: []time.Duration{5 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour},
retention: 90 * 24 * time.Hour, maxRows: 3, wantPaths: []string{"/p2", "/p3", "/p4"},
},
{
name: "都未触发保持原样", ages: []time.Duration{2 * time.Hour, time.Hour},
retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p0", "/p1"},
},
{
name: "过期与超量叠加", ages: []time.Duration{100 * 24 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour},
retention: 90 * 24 * time.Hour, maxRows: 2, wantPaths: []string{"/p3", "/p4"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, db := newSystemLogEnv(t)
for i, age := range tt.ages {
row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: time.Now().Add(-age)}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
if err := svc.cleanup(context.Background(), tt.retention, tt.maxRows); err != nil {
t.Fatalf("cleanup: %v", err)
}
var paths []string
if err := db.Model(&model.SystemLog{}).Order("created_at ASC").Pluck("path", &paths).Error; err != nil {
t.Fatalf("load rows: %v", err)
}
if fmt.Sprint(paths) != fmt.Sprint(tt.wantPaths) {
t.Errorf("kept = %v, want %v", paths, tt.wantPaths)
}
})
}
}
func TestSystemLogStartCleanupStopsOnCancel(t *testing.T) {
svc, _ := newSystemLogEnv(t)
ctx, cancel := context.WithCancel(context.Background())
svc.StartCleanup(ctx)
cancel()
done := make(chan struct{})
go func() {
svc.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("cleanup goroutine did not exit after context cancel")
}
}
+857
View File
@@ -0,0 +1,857 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// taskRunTimeout 是单次任务执行的超时时间。
const taskRunTimeout = 10 * time.Minute
// taskLogKeep 是每个任务保留的执行日志条数。
const taskLogKeep = 100
// TaskService 管理后台任务的存储、cron 调度与执行。
type TaskService struct {
db *gorm.DB
configs *OciConfigService
notifier *Notifier
settings *SettingService
cron *cron.Cron
// aiGateway 供 AI 探测任务执行渠道探测,由 main 装配(可为 nil)
aiGateway *AiGatewayService
mu sync.Mutex
entries map[uint]cron.EntryID
}
// NewTaskService 组装依赖;notifier 传 nil 表示整体关闭通知,
// settings 供发送前按事件类型过滤(nil 视为全开)。调用 Start 后开始调度。
func NewTaskService(db *gorm.DB, configs *OciConfigService, notifier *Notifier, settings *SettingService) *TaskService {
return &TaskService{
db: db,
configs: configs,
notifier: notifier,
settings: settings,
cron: cron.New(),
entries: map[uint]cron.EntryID{},
}
}
// AttachAiGateway 注入 AI 网关服务,启用 AI 探测任务的执行与自动同步。
func (s *TaskService) AttachAiGateway(gw *AiGatewayService) { s.aiGateway = gw }
// Start 加载全部 active 任务注册调度并启动 cron。
func (s *TaskService) Start() error {
var tasks []model.Task
if err := s.db.Where("status = ?", model.TaskStatusActive).Find(&tasks).Error; err != nil {
return fmt.Errorf("load active tasks: %w", err)
}
for i := range tasks {
if err := s.schedule(&tasks[i]); err != nil {
return err
}
}
s.cron.Start()
return nil
}
// Stop 停止调度并等待执行中的任务收尾,
// 保证任务产生的异步通知都已进入 Notifier 的等待队列。
func (s *TaskService) Stop() {
<-s.cron.Stop().Done()
}
// healthCheckPayload 是测活任务参数;ociConfigIds 为空表示全部配置。
type healthCheckPayload struct {
OciConfigIDs []uint `json:"ociConfigIds"`
}
// costPayload 是成本同步任务参数;ociConfigIds 为空表示全部配置,
// 免费类别的配置在执行时跳过,不发起 Usage API 请求。
type costPayload struct {
OciConfigIDs []uint `json:"ociConfigIds"`
}
// snatchPayload 是抢机任务参数;count 为剩余台数(创建时即目标台数,
// 每次执行把剩余写回,直到抢满),totalCount 固定为创建时的目标台数,
// 供前端计算进度(旧任务缺省时前端回退用 count)。
// authFailCount 为连续 NotAuthenticated 失败计数,达阈值任务熔断停止。
type snatchPayload struct {
OciConfigID uint `json:"ociConfigId"`
Count int `json:"count"`
TotalCount int `json:"totalCount,omitempty"`
AuthFailCount int `json:"authFailCount,omitempty"`
Instance oci.CreateInstanceInput `json:"instance"`
}
// CreateTaskInput 是创建任务的输入。
type CreateTaskInput struct {
Name string
Type string
CronExpr string
Payload json.RawMessage
}
// CreateTask 校验并保存任务,立即进入调度;AI 探测任务全局唯一(系统自动管理)。
func (s *TaskService) CreateTask(ctx context.Context, in CreateTaskInput) (*model.Task, error) {
if in.Name == "" {
return nil, fmt.Errorf("create task: name is required")
}
if _, err := cron.ParseStandard(in.CronExpr); err != nil {
return nil, fmt.Errorf("create task: invalid cron %q: %w", in.CronExpr, err)
}
if err := validateTaskPayload(in.Type, in.Payload); err != nil {
return nil, err
}
if err := s.ensureAiProbeUnique(ctx, in.Type); err != nil {
return nil, err
}
task := &model.Task{
Name: in.Name,
Type: in.Type,
CronExpr: in.CronExpr,
Payload: string(normalizeSnatchPayload(in.Type, in.Payload)),
Status: model.TaskStatusActive,
}
if err := s.db.WithContext(ctx).Create(task).Error; err != nil {
return nil, fmt.Errorf("create task: %w", err)
}
if err := s.schedule(task); err != nil {
return nil, err
}
return task, nil
}
// ensureAiProbeUnique 拒绝重复创建 AI 探测任务(该类型随渠道数量自动管理)。
func (s *TaskService) ensureAiProbeUnique(ctx context.Context, taskType string) error {
if taskType != model.TaskTypeAiProbe {
return nil
}
var n int64
if err := s.db.WithContext(ctx).Model(&model.Task{}).
Where("type = ?", model.TaskTypeAiProbe).Count(&n).Error; err != nil {
return err
}
if n > 0 {
return fmt.Errorf("create task: AI 探测任务已存在,由系统自动管理")
}
return nil
}
// normalizeSnatchPayload 给抢机 payload 补全目标台数:count 默认 1、
// totalCount 缺省时固定为创建时的 count,供进度展示;解析失败原样返回
// validateTaskPayload 已在前面拦截非法 JSON)。
func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMessage {
if taskType != model.TaskTypeSnatch {
return payload
}
var p snatchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return payload
}
if p.Count <= 0 {
p.Count = 1
}
if p.TotalCount <= 0 {
p.TotalCount = p.Count
}
out, err := json.Marshal(p)
if err != nil {
return payload
}
return out
}
// validateTaskPayload 按任务类型校验参数 JSON。
func validateTaskPayload(taskType string, payload json.RawMessage) error {
switch taskType {
case model.TaskTypeHealthCheck:
var p healthCheckPayload
if len(payload) > 0 {
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("create task: invalid payload: %w", err)
}
}
return nil
case model.TaskTypeCost:
var p costPayload
if len(payload) > 0 {
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("create task: invalid payload: %w", err)
}
}
return nil
case model.TaskTypeSnatch:
var p snatchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("create task: invalid payload: %w", err)
}
if p.OciConfigID == 0 {
return fmt.Errorf("create task: snatch payload requires ociConfigId")
}
return validateCreateInstance(p.Instance)
case model.TaskTypeAiProbe:
return nil // 无参数:探测全部渠道
default:
return fmt.Errorf("create task: unsupported type %q", taskType)
}
}
// UpdateTaskInput 是更新任务的输入;nil 字段不修改。
type UpdateTaskInput struct {
Name *string
CronExpr *string
Payload json.RawMessage
Status *string
}
// UpdateTask 修改任务并重新调度。
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
task, err := s.GetTask(ctx, id)
if err != nil {
return nil, err
}
if err := applyTaskUpdate(task, in); err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Save(task).Error; err != nil {
return nil, fmt.Errorf("update task %d: %w", id, err)
}
s.unschedule(task.ID)
if task.Status == model.TaskStatusActive {
if err := s.schedule(task); err != nil {
return nil, err
}
}
return task, nil
}
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
if in.Name != nil {
task.Name = *in.Name
}
if in.CronExpr != nil {
if _, err := cron.ParseStandard(*in.CronExpr); err != nil {
return fmt.Errorf("update task: invalid cron %q: %w", *in.CronExpr, err)
}
task.CronExpr = *in.CronExpr
}
if len(in.Payload) > 0 {
if err := validateTaskPayload(task.Type, in.Payload); err != nil {
return err
}
task.Payload = string(in.Payload)
}
if in.Status != nil {
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
return fmt.Errorf("update task: status must be active or paused")
}
if task.Status == model.TaskStatusFailed && *in.Status == model.TaskStatusActive {
resetSnatchAuthFail(task)
}
task.Status = *in.Status
}
return nil
}
// resetSnatchAuthFail 清零抢机 payload 的连续鉴权失败计数,
// 供 failed 任务重新启用时调用,避免一恢复调度就再次熔断;非抢机任务不处理。
func resetSnatchAuthFail(task *model.Task) {
if task.Type != model.TaskTypeSnatch {
return
}
var p snatchPayload
if err := json.Unmarshal([]byte(task.Payload), &p); err != nil {
return
}
p.AuthFailCount = 0
writeSnatchPayload(task, &p)
}
// ListTasks 返回全部任务。
func (s *TaskService) ListTasks(ctx context.Context) ([]model.Task, error) {
tasks := make([]model.Task, 0)
if err := s.db.WithContext(ctx).Order("id").Find(&tasks).Error; err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
return tasks, nil
}
// GetTask 返回单个任务。
func (s *TaskService) GetTask(ctx context.Context, id uint) (*model.Task, error) {
var task model.Task
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
return nil, fmt.Errorf("find task %d: %w", id, err)
}
return &task, nil
}
// DeleteTask 注销调度并删除任务与其日志;AI 探测任务由系统按渠道数量
// 自动创建/删除,拒绝手动删除。
func (s *TaskService) DeleteTask(ctx context.Context, id uint) error {
task, err := s.GetTask(ctx, id)
if err != nil {
return err
}
if task.Type == model.TaskTypeAiProbe {
return fmt.Errorf("delete task: AI 探测任务由系统自动管理,删除最后一个渠道时自动移除")
}
return s.removeTask(ctx, id)
}
// removeTask 注销调度并删除任务与其日志(内部路径,不做类型限制)。
func (s *TaskService) removeTask(ctx context.Context, id uint) error {
s.unschedule(id)
if err := s.db.WithContext(ctx).Delete(&model.Task{}, id).Error; err != nil {
return fmt.Errorf("delete task %d: %w", id, err)
}
if err := s.db.WithContext(ctx).Where("task_id = ?", id).Delete(&model.TaskLog{}).Error; err != nil {
return fmt.Errorf("delete task %d logs: %w", id, err)
}
return nil
}
// TaskLogs 返回任务最近的执行日志(时间倒序)。
func (s *TaskService) TaskLogs(ctx context.Context, id uint, limit int) ([]model.TaskLog, error) {
if limit <= 0 || limit > taskLogKeep {
limit = 50
}
logs := make([]model.TaskLog, 0)
err := s.db.WithContext(ctx).Where("task_id = ?", id).
Order("id desc").Limit(limit).Find(&logs).Error
if err != nil {
return nil, fmt.Errorf("list task %d logs: %w", id, err)
}
return logs, nil
}
// RunTaskNow 立即执行一次任务并返回本次日志。
func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog, error) {
if _, err := s.GetTask(ctx, id); err != nil {
return nil, err
}
return s.execute(id), nil
}
// schedule 把任务注册进 cron 调度。
func (s *TaskService) schedule(task *model.Task) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.entries[task.ID]; ok {
return nil
}
taskID := task.ID
entry, err := s.cron.AddFunc(task.CronExpr, func() { s.execute(taskID) })
if err != nil {
return fmt.Errorf("schedule task %d: %w", task.ID, err)
}
s.entries[task.ID] = entry
return nil
}
// unschedule 把任务移出 cron 调度。
func (s *TaskService) unschedule(taskID uint) {
s.mu.Lock()
defer s.mu.Unlock()
if entry, ok := s.entries[taskID]; ok {
s.cron.Remove(entry)
delete(s.entries, taskID)
}
}
// execute 执行一次任务:加载 → 分派 → 更新任务状态并写日志,
// 前后状态交给通知判定,只在状态变化时推送。
func (s *TaskService) execute(taskID uint) *model.TaskLog {
ctx, cancel := context.WithTimeout(context.Background(), taskRunTimeout)
defer cancel()
task, err := s.GetTask(ctx, taskID)
if err != nil {
return nil
}
prev := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError}
start := time.Now()
message, runErr := s.run(ctx, task)
now := time.Now()
task.LastRunAt = &now
task.RunCount++
task.LastError = ""
if runErr != nil {
task.LastError = oci.CompactError(runErr)
message = task.LastError
}
s.db.Save(task)
cur := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError, Message: message}
s.notify(notifyEvents(prev, cur))
return s.appendLog(task.ID, runErr == nil, message, time.Since(start))
}
// run 按类型分派任务执行。
func (s *TaskService) run(ctx context.Context, task *model.Task) (string, error) {
switch task.Type {
case model.TaskTypeHealthCheck:
return s.runHealthCheck(ctx, task)
case model.TaskTypeCost:
return s.runCost(ctx, task)
case model.TaskTypeSnatch:
return s.runSnatch(ctx, task)
case model.TaskTypeAiProbe:
return s.runAiProbe(ctx)
default:
return "", fmt.Errorf("unsupported task type %q", task.Type)
}
}
// runAiProbe 逐渠道探测 AI 网关号池;网关未装配时报错。
func (s *TaskService) runAiProbe(ctx context.Context) (string, error) {
if s.aiGateway == nil {
return "", fmt.Errorf("ai gateway not attached")
}
msg, err := s.aiGateway.ProbeAll(ctx)
if err == nil {
s.warnDeprecatingModels(ctx)
}
return msg, err
}
// warnDeprecatingModels 对 30 天内即将退役或弃用的在池模型发 Telegram 提醒;
// 随每日探测执行,模型退役被同步剔除后自动停止,受通知管理 model_deprecated 开关控制。
func (s *TaskService) warnDeprecatingModels(ctx context.Context) {
if s.notifier == nil {
return
}
if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, "model_deprecated") {
return
}
names, err := s.aiGateway.DeprecatingModels(ctx, 30*24*time.Hour)
if err != nil || len(names) == 0 {
return
}
s.notifier.SendTemplateAsync("model_deprecated", map[string]string{"models": strings.Join(names, "\n")})
}
// runHealthCheck 对范围内的配置逐个测活,汇总结果并触发失联通知。
func (s *TaskService) runHealthCheck(ctx context.Context, task *model.Task) (string, error) {
var p healthCheckPayload
if task.Payload != "" {
if err := json.Unmarshal([]byte(task.Payload), &p); err != nil {
return "", fmt.Errorf("parse payload: %w", err)
}
}
ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs)
if err != nil {
return "", err
}
alive := 0
var deadAliases, failures []string
for _, id := range ids {
cfg, _, err := s.configs.Verify(ctx, id)
ok := err == nil && cfg.AliveStatus == model.AliveStatusAlive
s.saveCheckSnapshot(ctx, id, ok)
if !ok {
deadAliases = append(deadAliases, configAlias(cfg, id))
failures = append(failures, fmt.Sprintf("#%d %s", id, verifyFailReason(cfg, err)))
continue
}
alive++
}
s.notifyDeadAliases(ctx, task.ID, deadAliases)
msg := fmt.Sprintf("checked %d: %d alive, %d dead", len(ids), alive, len(deadAliases))
if len(failures) > 0 {
msg += "; " + strings.Join(failures, "; ")
}
return msg, nil
}
// configAlias 返回配置别名,配置加载失败时退回 #ID 表示。
func configAlias(cfg *model.OciConfig, id uint) string {
if cfg != nil && cfg.Alias != "" {
return cfg.Alias
}
return fmt.Sprintf("#%d", id)
}
func verifyFailReason(cfg *model.OciConfig, err error) string {
if err != nil {
return oci.CompactError(err)
}
return cfg.LastError
}
// saveCheckSnapshot 覆盖写入测活快照;仅存活时刷新实例数(默认区域口径),
// 失联时保留上次实例数,避免总览 KPI 因 key 失效而抖动。
func (s *TaskService) saveCheckSnapshot(ctx context.Context, cfgID uint, alive bool) {
status := model.AliveStatusDead
if alive {
status = model.AliveStatusAlive
}
snap := model.CheckSnapshot{OciConfigID: cfgID, AliveStatus: status, CheckedAt: time.Now()}
cols := []string{"alive_status", "checked_at"}
if alive {
if instances, err := s.configs.Instances(ctx, cfgID, "", ""); err == nil {
snap.InstanceCount = len(instances)
cols = append(cols, "instance_count")
}
}
s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "oci_config_id"}},
DoUpdates: clause.AssignmentColumns(cols),
}).Create(&snap)
}
// runCost 对范围内配置同步近 7 天每日成本快照,免费类别跳过。
func (s *TaskService) runCost(ctx context.Context, task *model.Task) (string, error) {
var p costPayload
if task.Payload != "" {
if err := json.Unmarshal([]byte(task.Payload), &p); err != nil {
return "", fmt.Errorf("parse payload: %w", err)
}
}
ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs)
if err != nil {
return "", err
}
synced, skipped := 0, 0
var failures []string
for _, id := range ids {
switch err := s.syncCostSnapshot(ctx, id); {
case err == errFreeAccountSkipped:
skipped++
case err != nil:
failures = append(failures, fmt.Sprintf("#%d %v", id, err))
default:
synced++
}
}
msg := fmt.Sprintf("synced usage for %d tenants, skipped %d free", synced, skipped)
if len(failures) > 0 {
msg += "; " + strings.Join(failures, "; ")
}
return msg, nil
}
// errFreeAccountSkipped 标记成本同步因免费类别被跳过。
var errFreeAccountSkipped = fmt.Errorf("free account skipped")
// syncCostSnapshot 拉取单配置近 7 天每日成本并按天覆盖写入快照。
func (s *TaskService) syncCostSnapshot(ctx context.Context, cfgID uint) error {
cfg, err := s.configs.Get(ctx, cfgID)
if err != nil {
return err
}
if cfg.AccountType == model.AccountTypeFree {
return errFreeAccountSkipped
}
end := time.Now().UTC()
items, err := s.configs.Costs(ctx, cfgID, oci.CostQuery{
StartTime: end.AddDate(0, 0, -7),
EndTime: end,
})
if err != nil {
return err
}
return s.saveCostSnapshots(ctx, cfgID, items)
}
// saveCostSnapshots 把成本条目按 UTC 日聚合后逐日 upsert。
func (s *TaskService) saveCostSnapshots(ctx context.Context, cfgID uint, items []oci.CostItem) error {
type bucket struct {
amount float64
currency string
}
byDay := map[string]*bucket{}
for _, item := range items {
if item.TimeStart == nil {
continue
}
day := item.TimeStart.UTC().Format("2006-01-02")
b, ok := byDay[day]
if !ok {
b = &bucket{currency: item.Currency}
byDay[day] = b
}
b.amount += float64(item.ComputedAmount)
}
now := time.Now()
for day, b := range byDay {
snap := model.CostSnapshot{
OciConfigID: cfgID, Day: day,
Amount: b.amount, Currency: b.currency, SyncedAt: now,
}
err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "oci_config_id"}, {Name: "day"}},
DoUpdates: clause.AssignmentColumns([]string{"amount", "currency", "synced_at"}),
}).Create(&snap).Error
if err != nil {
return fmt.Errorf("save cost snapshot %s: %w", day, err)
}
}
return nil
}
// targetConfigIDs 解析任务作用范围;未指定时返回全部配置 ID。
func (s *TaskService) targetConfigIDs(ctx context.Context, ids []uint) ([]uint, error) {
if len(ids) > 0 {
return ids, nil
}
configs, err := s.configs.List(ctx)
if err != nil {
return nil, err
}
all := make([]uint, 0, len(configs))
for _, cfg := range configs {
all = append(all, cfg.ID)
}
return all, nil
}
// runSnatch 尝试创建实例;抢到目标台数后任务标记 succeeded 并停止调度,
// 部分成功把剩余台数写回 payload 下次继续;成功路径一并清零并写回连续
// 鉴权失败计数,失败路径交给 snatchFailure 做连续 NotAuthenticated 熔断
// 判定。字段落库由 execute 统一 Save。
func (s *TaskService) runSnatch(ctx context.Context, task *model.Task) (string, error) {
var p snatchPayload
if err := json.Unmarshal([]byte(task.Payload), &p); err != nil {
return "", fmt.Errorf("parse payload: %w", err)
}
if p.Count < 1 {
p.Count = 1
}
in, adNote, err := s.snatchInstanceInput(ctx, task, &p)
if err != nil {
return "", s.snatchFailure(ctx, task, &p, err)
}
instances, failures, err := s.configs.CreateInstances(ctx, p.OciConfigID, in, p.Count)
if err == nil && len(instances) == 0 {
err = fmt.Errorf("no instance created%s: %s", adNote, strings.Join(failures, "; "))
}
if err != nil {
return "", s.snatchFailure(ctx, task, &p, err)
}
p.AuthFailCount = 0
ids := make([]string, 0, len(instances))
for _, in := range instances {
ids = append(ids, in.ID)
}
remaining := p.Count - len(instances)
if remaining > 0 {
p.Count = remaining
writeSnatchPayload(task, &p)
return fmt.Sprintf("created %d (%s)%s, %d remaining", len(instances), strings.Join(ids, ","), adNote, remaining), nil
}
task.Status = model.TaskStatusSucceeded
writeSnatchPayload(task, &p)
s.unschedule(task.ID)
return fmt.Sprintf("created %d: %s%s", len(instances), strings.Join(ids, ","), adNote), nil
}
// snatchInstanceInput 组装本次创建参数:可用域显式指定时原样使用;
// 留空(自动)时按执行序号轮询区域全部可用域——ad-1、ad-2、ad-3 依次循环,
// 分摊单可用域容量不足。附加说明串供执行日志展示本次所用可用域。
func (s *TaskService) snatchInstanceInput(ctx context.Context, task *model.Task, p *snatchPayload) (oci.CreateInstanceInput, string, error) {
in := p.Instance
if in.AvailabilityDomain != "" {
return in, "", nil
}
ads, err := s.configs.AvailabilityDomains(ctx, p.OciConfigID, in.Region)
if err != nil {
return in, "", fmt.Errorf("list availability domains: %w", err)
}
if len(ads) == 0 {
return in, "", fmt.Errorf("region has no availability domain")
}
// execute 在 run 之后才递增 RunCount,此处即 0 起的本次执行序号
in.AvailabilityDomain = ads[task.RunCount%len(ads)]
return in, " @ " + in.AvailabilityDomain, nil
}
// snatchFailure 处理抢机单次失败:错误含 NotAuthenticated 时累计连续计数,
// 达阈值把任务置 failed 并移出调度(熔断);其他错误清零计数。计数写回 payload。
func (s *TaskService) snatchFailure(ctx context.Context, task *model.Task, p *snatchPayload, cause error) error {
if !strings.Contains(cause.Error(), "NotAuthenticated") {
p.AuthFailCount = 0
writeSnatchPayload(task, p)
return cause
}
p.AuthFailCount++
writeSnatchPayload(task, p)
if p.AuthFailCount < s.snatchAuthFailLimit(ctx) {
return cause
}
task.Status = model.TaskStatusFailed
s.unschedule(task.ID)
return fmt.Errorf("连续 %d 次 NotAuthenticated,任务已熔断停止: %w", p.AuthFailCount, cause)
}
// snatchAuthFailLimit 读取抢机熔断阈值;settings 未注入或读取失败按默认值。
func (s *TaskService) snatchAuthFailLimit(ctx context.Context) int {
if s.settings == nil {
return defaultSnatchAuthFailLimit
}
view, err := s.settings.TaskSettings(ctx)
if err != nil {
return defaultSnatchAuthFailLimit
}
return view.SnatchAuthFailLimit
}
// writeSnatchPayload 把最新抢机参数序列化写回任务(execute 统一落库)。
func writeSnatchPayload(task *model.Task, p *snatchPayload) {
if raw, err := json.Marshal(p); err == nil {
task.Payload = string(raw)
}
}
// appendLog 写入执行日志并裁剪超出保留数量的旧日志。
func (s *TaskService) appendLog(taskID uint, success bool, message string, elapsed time.Duration) *model.TaskLog {
entry := &model.TaskLog{
TaskID: taskID,
Success: success,
Message: message,
DurationMs: elapsed.Milliseconds(),
}
s.db.Create(entry)
s.db.Where("task_id = ? AND id NOT IN (?)", taskID,
s.db.Model(&model.TaskLog{}).Select("id").Where("task_id = ?", taskID).
Order("id desc").Limit(taskLogKeep),
).Delete(&model.TaskLog{})
return entry
}
// taskSnapshot 是通知判定所需的任务状态切片(执行前后各取一份)。
type taskSnapshot struct {
Name string
Status string
LastError string
Message string // 本次执行结果摘要,仅执行后快照填写
}
// notifyKind 是通知事件类型,与设置页「通知管理」开关一一对应。
type notifyKind string
const (
notifyTaskFail notifyKind = "task_fail"
notifyTaskRecover notifyKind = "task_recover"
notifySnatchSuccess notifyKind = "snatch_success"
notifyTenantDead notifyKind = "tenant_dead"
notifyTaskStop notifyKind = "task_stop" // 任务熔断停止(抢机连续鉴权失败达阈值)
)
// notifyEvent 是一条待发送的通知:类型供开关过滤与模板选择,Vars 为模板变量。
type notifyEvent struct {
Kind notifyKind
Vars map[string]string
}
// notifyEvents 比较执行前后的任务状态,返回需要推送的通知事件。
// 只在状态发生变化时产出:连续失败或持续正常都不重复发,防轰炸。
// 熔断翻转(置 failed)优先判定,该次只发任务停止、不叠加任务失败。
func notifyEvents(prev, cur taskSnapshot) []notifyEvent {
var events []notifyEvent
if prev.Status != model.TaskStatusSucceeded && cur.Status == model.TaskStatusSucceeded {
events = append(events, notifyEvent{notifySnatchSuccess, map[string]string{"task_name": cur.Name, "message": cur.Message}})
}
switch {
case prev.Status != model.TaskStatusFailed && cur.Status == model.TaskStatusFailed:
events = append(events, notifyEvent{notifyTaskStop, map[string]string{"task_name": cur.Name, "error": cur.LastError}})
case prev.LastError == "" && cur.LastError != "":
events = append(events, notifyEvent{notifyTaskFail, map[string]string{"task_name": cur.Name, "error": cur.LastError}})
case prev.LastError != "" && cur.LastError == "" && cur.Status != model.TaskStatusSucceeded:
// 恢复即成功收尾(抢机达成目标)时已有抢机成功通知,不再叠加恢复通知
events = append(events, notifyEvent{notifyTaskRecover, map[string]string{"task_name": cur.Name}})
}
return events
}
// notifyFilterTimeout 是发送前查询事件开关的超时时间(本地 SQLite,查询极快)。
const notifyFilterTimeout = 5 * time.Second
// notify 逐条按事件开关过滤后异步发送;notifier 未注入(nil)时整体关闭。
// 开关读取失败按开启降级(NotifyEventEnabled 内部兜底),不因设置异常漏发。
func (s *TaskService) notify(events []notifyEvent) {
if s.notifier == nil || len(events) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), notifyFilterTimeout)
defer cancel()
for _, ev := range events {
if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, string(ev.Kind)) {
continue
}
s.notifier.SendTemplateAsync(string(ev.Kind), ev.Vars)
}
}
// deadAliasKey 是测活任务留存上次失联别名集合的 Setting 键。
func deadAliasKey(taskID uint) string {
return fmt.Sprintf("health_dead_alias:%d", taskID)
}
// notifyDeadAliases 只在失联集合发生变化时推送失联通知,并留存本次集合;
// 集合不变(含持续失联)不重复发。notifier 未注入时整体跳过。
func (s *TaskService) notifyDeadAliases(ctx context.Context, taskID uint, aliases []string) {
if s.notifier == nil {
return
}
if sameStringSet(s.loadDeadAliases(ctx, taskID), aliases) {
return
}
s.saveDeadAliases(ctx, taskID, aliases)
if len(aliases) > 0 {
s.notify([]notifyEvent{{notifyTenantDead, map[string]string{"tenants": strings.Join(aliases, "、")}}})
}
}
// loadDeadAliases 读取任务上次记录的失联别名集合;无记录视为空集。
func (s *TaskService) loadDeadAliases(ctx context.Context, taskID uint) []string {
var st model.Setting
err := s.db.WithContext(ctx).First(&st, "key = ?", deadAliasKey(taskID)).Error
if err != nil || st.Value == "" {
return nil
}
var aliases []string
if err := json.Unmarshal([]byte(st.Value), &aliases); err != nil {
return nil
}
return aliases
}
// saveDeadAliases 覆盖保存本次失联别名集合;留存失败不影响任务执行。
func (s *TaskService) saveDeadAliases(ctx context.Context, taskID uint, aliases []string) {
raw, err := json.Marshal(aliases)
if err != nil {
return
}
s.db.WithContext(ctx).Save(&model.Setting{
Key: deadAliasKey(taskID), Value: string(raw), UpdatedAt: time.Now(),
})
}
// sameStringSet 判断两个字符串切片内容是否相同(忽略顺序,重复元素按次数计)。
func sameStringSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
count := make(map[string]int, len(a))
for _, v := range a {
count[v]++
}
for _, v := range b {
count[v]--
if count[v] < 0 {
return false
}
}
return true
}
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"context"
"errors"
"log"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// AI 探测任务由系统按渠道数量自动管理:每天 00:00 探测一次全部号池渠道
// (逐渠道使用该渠道自己的租户凭据,与号池一一对应)。
const (
aiProbeTaskName = "AI渠道探测"
aiProbeCron = "0 0 * * *"
)
// SyncAiProbeTask 按渠道数量同步 AI 探测任务:
// 渠道数 > 0 时确保任务存在且激活,归零时连同日志自动删除;
// 由渠道增删钩子与启动时调用。cron 与代码基线不一致时一并对齐(升级自动迁移)。
func (s *TaskService) SyncAiProbeTask(ctx context.Context) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Count(&count).Error; err != nil {
return
}
var task model.Task
err := s.db.WithContext(ctx).Where("type = ?", model.TaskTypeAiProbe).First(&task).Error
notFound := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !notFound {
return
}
if !notFound && task.CronExpr != aiProbeCron {
s.alignAiProbeCron(ctx, &task)
}
if count > 0 {
s.activateAiProbe(ctx, &task, notFound)
return
}
if !notFound {
if err := s.removeTask(ctx, task.ID); err != nil {
log.Printf("sync ai probe task: %v", err)
}
}
}
// alignAiProbeCron 把存量任务的执行计划对齐到当前基线。
func (s *TaskService) alignAiProbeCron(ctx context.Context, task *model.Task) {
cron := aiProbeCron
if _, err := s.UpdateTask(ctx, task.ID, UpdateTaskInput{CronExpr: &cron}); err != nil {
log.Printf("align ai probe cron: %v", err)
return
}
task.CronExpr = aiProbeCron
}
// activateAiProbe 创建或激活 AI 探测任务。
func (s *TaskService) activateAiProbe(ctx context.Context, task *model.Task, notFound bool) {
if notFound {
in := CreateTaskInput{Name: aiProbeTaskName, Type: model.TaskTypeAiProbe, CronExpr: aiProbeCron}
if _, err := s.CreateTask(ctx, in); err != nil {
log.Printf("sync ai probe task: %v", err)
}
return
}
if task.Status == model.TaskStatusActive {
return
}
active := model.TaskStatusActive
if _, err := s.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active}); err != nil {
log.Printf("sync ai probe task: %v", err)
}
}
+701
View File
@@ -0,0 +1,701 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// snatchFake 可控 LaunchInstance 结果:前 failFirst 次失败模拟容量不足。
type snatchFake struct {
fakeClient
launches int
failFirst int
}
func (f *snatchFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.launches++
if f.launches <= f.failFirst {
return oci.Instance{}, fmt.Errorf("Out of host capacity")
}
return oci.Instance{ID: fmt.Sprintf("ocid1.instance.%d", f.launches), DisplayName: in.DisplayName}, nil
}
// scriptedSnatchFake 按脚本逐次控制 LaunchInstance 结果:nil 表示成功;
// 脚本耗尽后一律成功。
type scriptedSnatchFake struct {
fakeClient
mu sync.Mutex
script []error
launches int
}
func (f *scriptedSnatchFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.launches++
if len(f.script) > 0 {
err := f.script[0]
f.script = f.script[1:]
if err != nil {
return oci.Instance{}, err
}
}
return oci.Instance{ID: fmt.Sprintf("ocid1.instance.%d", f.launches), DisplayName: in.DisplayName}, nil
}
func newTaskEnv(t *testing.T, client oci.Client) (*TaskService, *OciConfigService, *gorm.DB) {
t.Helper()
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.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.CheckSnapshot{}, &model.CostSnapshot{}, &model.Setting{}, &model.Proxy{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
configs := NewOciConfigService(db, cipher, client)
return NewTaskService(db, configs, nil, NewSettingService(db, cipher)), configs, db
}
func TestCreateTaskValidation(t *testing.T) {
tasks, _, _ := newTaskEnv(t, &fakeClient{})
tests := []struct {
name string
in CreateTaskInput
}{
{name: "非法 cron", in: CreateTaskInput{Name: "t", Type: model.TaskTypeHealthCheck, CronExpr: "not-cron"}},
{name: "未知类型", in: CreateTaskInput{Name: "t", Type: "nope", CronExpr: "* * * * *"}},
{name: "缺名称", in: CreateTaskInput{Type: model.TaskTypeHealthCheck, CronExpr: "* * * * *"}},
{
name: "抢机缺配置ID",
in: CreateTaskInput{Name: "t", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"instance":{"displayName":"a","shape":"s","imageId":"i"}}`)},
},
{
name: "抢机实例参数不全",
in: CreateTaskInput{Name: "t", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"shape":"s"}}`)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := tasks.CreateTask(context.Background(), tt.in); err == nil {
t.Error("CreateTask succeeded, want error")
}
})
}
}
func TestRunHealthCheckTask(t *testing.T) {
client := &fakeClient{tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
second := trialImportInput()
second.Alias = "第二个"
if _, err := configs.Import(ctx, second); err != nil {
t.Fatalf("import second: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil {
t.Fatalf("RunTaskNow: %v", err)
}
if !entry.Success || entry.Message != "checked 2: 2 alive, 0 dead" {
t.Errorf("log = %+v, want success checked 2", entry)
}
scoped, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "范围测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *",
Payload: json.RawMessage(`{"ociConfigIds":[1]}`),
})
if err != nil {
t.Fatalf("CreateTask scoped: %v", err)
}
entry, err = tasks.RunTaskNow(ctx, scoped.ID)
if err != nil {
t.Fatalf("RunTaskNow scoped: %v", err)
}
if entry.Message != "checked 1: 1 alive, 0 dead" {
t.Errorf("scoped message = %q, want checked 1", entry.Message)
}
}
func TestRunSnatchTaskUntilSuccess(t *testing.T) {
client := &snatchFake{failFirst: 2}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"snatch-vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}}`),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// 前两次容量不足:任务保持 active 并记录失败
for i := 1; i <= 2; i++ {
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil {
t.Fatalf("run %d: %v", i, err)
}
if entry.Success {
t.Fatalf("run %d succeeded, want capacity failure", i)
}
}
got, _ := tasks.GetTask(ctx, task.ID)
if got.Status != model.TaskStatusActive || got.RunCount != 2 || got.LastError == "" {
t.Errorf("after failures: status=%s runs=%d lastError=%q", got.Status, got.RunCount, got.LastError)
}
// 第三次成功:任务自动标记 succeeded
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil {
t.Fatalf("run 3: %v", err)
}
if !entry.Success {
t.Fatalf("run 3 = %+v, want success", entry)
}
got, _ = tasks.GetTask(ctx, task.ID)
if got.Status != model.TaskStatusSucceeded || got.LastError != "" {
t.Errorf("after success: status=%s lastError=%q, want succeeded", got.Status, got.LastError)
}
logs, err := tasks.TaskLogs(ctx, task.ID, 10)
if err != nil {
t.Fatalf("TaskLogs: %v", err)
}
if len(logs) != 3 || !logs[0].Success {
t.Errorf("logs = %d entries, newest success=%v; want 3 with newest success", len(logs), logs[0].Success)
}
}
// adRotationFake 记录每次 LaunchInstance 收到的可用域;创建一律按容量不足
// 失败,任务保持 active 以便连续执行验证轮询顺序。
type adRotationFake struct {
fakeClient
ads []string
got []string
}
func (f *adRotationFake) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) {
return f.ads, nil
}
func (f *adRotationFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.got = append(f.got, in.AvailabilityDomain)
return oci.Instance{}, fmt.Errorf("Out of host capacity")
}
// TestSnatchAdRotation 可用域留空(自动)时按执行序号轮询区域全部可用域,
// 显式指定时不轮询。
func TestSnatchAdRotation(t *testing.T) {
client := &adRotationFake{ads: []string{"ad-1", "ad-2", "ad-3"}}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
for i := 0; i < 4; i++ {
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("run %d: %v", i, err)
}
}
if got := strings.Join(client.got, ","); got != "ad-1,ad-2,ad-3,ad-1" {
t.Errorf("自动轮询序列 = %s, want ad-1,ad-2,ad-3,ad-1", got)
}
client.got = nil
fixed, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "定向", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i","availabilityDomain":"ad-2"}}`),
})
if err != nil {
t.Fatalf("CreateTask fixed: %v", err)
}
for i := 0; i < 2; i++ {
if _, err := tasks.RunTaskNow(ctx, fixed.ID); err != nil {
t.Fatalf("run fixed %d: %v", i, err)
}
}
if got := strings.Join(client.got, ","); got != "ad-2,ad-2" {
t.Errorf("显式指定序列 = %s, want ad-2,ad-2", got)
}
}
func TestSnatchPartialSuccessKeepsRemaining(t *testing.T) {
client := &snatchFake{failFirst: 1} // 第 1 台失败、第 2 台成功
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢两台", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"count":2,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
entry, err := tasks.RunTaskNow(ctx, task.ID)
if err != nil {
t.Fatalf("RunTaskNow: %v", err)
}
if !entry.Success {
t.Fatalf("log = %+v, want partial success", entry)
}
got, _ := tasks.GetTask(ctx, task.ID)
if got.Status != model.TaskStatusActive {
t.Errorf("status = %s, want active(还差 1 台)", got.Status)
}
var p snatchPayload
if err := json.Unmarshal([]byte(got.Payload), &p); err != nil || p.Count != 1 {
t.Errorf("payload count = %d (%v), want 1", p.Count, err)
}
}
func TestUpdateTaskPauseResume(t *testing.T) {
tasks, _, _ := newTaskEnv(t, &fakeClient{})
ctx := context.Background()
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
paused := model.TaskStatusPaused
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &paused})
if err != nil {
t.Fatalf("pause: %v", err)
}
if updated.Status != model.TaskStatusPaused {
t.Errorf("status = %s, want paused", updated.Status)
}
if _, ok := tasks.entries[task.ID]; ok {
t.Error("paused task still scheduled")
}
active := model.TaskStatusActive
cronExpr := "*/30 * * * *"
updated, err = tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active, CronExpr: &cronExpr})
if err != nil {
t.Fatalf("resume: %v", err)
}
if updated.CronExpr != cronExpr || updated.Status != model.TaskStatusActive {
t.Errorf("updated = %+v, want active with new cron", updated)
}
if _, ok := tasks.entries[task.ID]; !ok {
t.Error("resumed task not scheduled")
}
}
// enabledTelegramInput 返回启用态的 Telegram 配置输入,供通知测试复用。
func enabledTelegramInput() *UpdateTelegramInput {
token := "123456:AAfake"
return &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}
}
func TestSnatchRepeatedFailureNotifiesOnce(t *testing.T) {
client := &snatchFake{failFirst: 3}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
srv, rec := newFakeTelegram(t, `{"ok":true}`)
tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput())
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// 连续失败 3 次:只发 1 条失败通知
for i := 1; i <= 3; i++ {
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("run %d: %v", i, err)
}
}
tasks.notifier.Wait()
if texts := rec.snapshot(); len(texts) != 1 || !strings.Contains(texts[0], "任务失败") {
t.Fatalf("失败阶段通知 = %v, want 仅 1 条任务失败", texts)
}
// 第 4 次抢满:追加 1 条抢机成功,不叠加恢复通知
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("run 4: %v", err)
}
tasks.notifier.Wait()
texts := rec.snapshot()
if len(texts) != 2 || !strings.Contains(texts[1], "抢机成功") {
t.Fatalf("成功后通知 = %v, want 追加 1 条抢机成功", texts)
}
}
// TestNotifyFiltersDisabledKinds 验证按事件类型过滤:关闭「任务失败」后
// 失败不再推送,任务恢复与抢机成功不受影响仍推送。
func TestNotifyFiltersDisabledKinds(t *testing.T) {
client := &snatchFake{failFirst: 3}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
srv, rec := newFakeTelegram(t, `{"ok":true}`)
tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput())
ctx := context.Background()
off := NotifyEventsView{TaskRecover: true, SnatchSuccess: true, TenantDead: true, TaskStop: true}
if err := tasks.settings.UpdateNotifyEvents(ctx, off); err != nil {
t.Fatalf("UpdateNotifyEvents: %v", err)
}
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢两台", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(`{"ociConfigId":1,"count":2,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// 三次执行依次触发:task_fail(已关闭)→ task_recover → snatch_success
steps := []struct {
name string
wantLen int // 执行后累计推送条数
wantKw string // 最新一条需包含的关键词;空表示本步无新增
}{
{name: "两台全失败:任务失败已关闭不发", wantLen: 0},
{name: "抢到 1 台剩 1:任务恢复仍发", wantLen: 1, wantKw: "任务恢复"},
{name: "抢满收尾:抢机成功仍发", wantLen: 2, wantKw: "抢机成功"},
}
for _, step := range steps {
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("%s: %v", step.name, err)
}
tasks.notifier.Wait()
texts := rec.snapshot()
if len(texts) != step.wantLen {
t.Fatalf("%s: 通知 = %v (%d 条), want %d 条", step.name, texts, len(texts), step.wantLen)
}
if step.wantKw != "" && !strings.Contains(texts[len(texts)-1], step.wantKw) {
t.Fatalf("%s: 最新通知 = %q, want contains %q", step.name, texts[len(texts)-1], step.wantKw)
}
}
}
// deadPickFake 按 UserOCID 控制测活结果,模拟部分租户失联。
type deadPickFake struct {
fakeClient
mu sync.Mutex
dead map[string]bool
}
func (f *deadPickFake) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.dead[cred.UserOCID] {
return oci.TenancyInfo{}, fmt.Errorf("NotAuthenticated")
}
return f.tenancy, nil
}
func (f *deadPickFake) setDead(user string, dead bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.dead[user] = dead
}
func TestHealthCheckNotifiesDeadAliasOnChange(t *testing.T) {
client := &deadPickFake{dead: map[string]bool{}}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
srv, rec := newFakeTelegram(t, `{"ok":true}`)
tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput())
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import A: %v", err)
}
second := trialImportInput()
second.Alias = "second-tenant"
second.UserOCID = "ocid1.user.oc1..u2"
if _, err := configs.Import(ctx, second); err != nil {
t.Fatalf("import B: %v", err)
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
run := func(step string) {
t.Helper()
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("%s: %v", step, err)
}
tasks.notifier.Wait()
}
run("全部存活")
client.setDead("ocid1.user.oc1..u2", true)
run("B 失联") // 集合变化:发 1 条
run("B 持续失联") // 集合不变:不发
client.setDead("ocid1.user.oc1..u2", false)
run("B 恢复") // 集合清空:不发失联通知
client.setDead("ocid1.user.oc1..u2", true)
run("B 再失联") // 集合再次变化:再发 1 条
texts := rec.snapshot()
if len(texts) != 2 {
t.Fatalf("失联通知 %d 条 %v, want 2 条", len(texts), texts)
}
for i, text := range texts {
if !strings.Contains(text, "租户失联") || !strings.Contains(text, "second-tenant") {
t.Errorf("text[%d] = %q, want 含「租户失联」与失联别名", i, text)
}
}
}
// snatchAuthCount 解析任务 payload 中的连续鉴权失败计数。
func snatchAuthCount(t *testing.T, payload string) int {
t.Helper()
var p snatchPayload
if err := json.Unmarshal([]byte(payload), &p); err != nil {
t.Fatalf("parse payload %q: %v", payload, err)
}
return p.AuthFailCount
}
func TestSnatchAuthFuse(t *testing.T) {
notAuth := fmt.Errorf("NotAuthenticated: 鉴权失败")
capacity := fmt.Errorf("Out of host capacity")
tests := []struct {
name string
limit int // 0 表示不设置,用默认 3
count int // 目标台数;0 按 1 处理
script []error
runs int
wantStatus string
wantAuthCnt int
}{
{
name: "连续 2 次不熔断", script: []error{notAuth, notAuth}, runs: 2,
wantStatus: model.TaskStatusActive, wantAuthCnt: 2,
},
{
name: "默认阈值第 3 次熔断", script: []error{notAuth, notAuth, notAuth}, runs: 3,
wantStatus: model.TaskStatusFailed, wantAuthCnt: 3,
},
{
name: "中途成功清零", count: 2, script: []error{notAuth, notAuth, nil, capacity}, runs: 2,
wantStatus: model.TaskStatusActive, wantAuthCnt: 0,
},
{
name: "中途其他错误清零", script: []error{notAuth, capacity}, runs: 2,
wantStatus: model.TaskStatusActive, wantAuthCnt: 0,
},
{
name: "抢满收尾清零并写回", script: []error{notAuth, nil}, runs: 2,
wantStatus: model.TaskStatusSucceeded, wantAuthCnt: 0,
},
{
name: "limit=1 立即熔断", limit: 1, script: []error{notAuth}, runs: 1,
wantStatus: model.TaskStatusFailed, wantAuthCnt: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
task, tasks := runAuthFuseCase(t, tt.limit, tt.count, tt.script, tt.runs)
got, err := tasks.GetTask(context.Background(), task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if got.Status != tt.wantStatus {
t.Errorf("status = %s, want %s", got.Status, tt.wantStatus)
}
if cnt := snatchAuthCount(t, got.Payload); cnt != tt.wantAuthCnt {
t.Errorf("authFailCount = %d, want %d", cnt, tt.wantAuthCnt)
}
assertAuthFuseSideEffects(t, tasks, got, tt.wantStatus, tt.wantAuthCnt)
})
}
}
// runAuthFuseCase 搭好环境并按用例参数执行 runs 次抢机任务。
func runAuthFuseCase(t *testing.T, limit, count int, script []error, runs int) (*model.Task, *TaskService) {
t.Helper()
client := &scriptedSnatchFake{script: script}
client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}
tasks, configs, _ := newTaskEnv(t, client)
ctx := context.Background()
if _, err := configs.Import(ctx, trialImportInput()); err != nil {
t.Fatalf("import: %v", err)
}
if limit > 0 {
if err := tasks.settings.UpdateTaskSettings(ctx, TaskSettingsView{SnatchAuthFailLimit: limit}); err != nil {
t.Fatalf("UpdateTaskSettings: %v", err)
}
}
if count == 0 {
count = 1
}
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(fmt.Sprintf(
`{"ociConfigId":1,"count":%d,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`, count)),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
for i := 1; i <= runs; i++ {
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("run %d: %v", i, err)
}
}
return task, tasks
}
// assertAuthFuseSideEffects 断言熔断副作用:仅 active 保持调度(failed 熔断、
// succeeded 抢满均移出);failed 的 LastError 须带熔断说明,其余不得出现。
func assertAuthFuseSideEffects(t *testing.T, tasks *TaskService, got *model.Task, wantStatus string, wantCnt int) {
t.Helper()
if _, scheduled := tasks.entries[got.ID]; scheduled != (wantStatus == model.TaskStatusActive) {
t.Errorf("scheduled = %v, want %v", scheduled, wantStatus == model.TaskStatusActive)
}
fused := wantStatus == model.TaskStatusFailed
fuseMsg := fmt.Sprintf("连续 %d 次 NotAuthenticated,任务已熔断停止", wantCnt)
if fused && !strings.Contains(got.LastError, fuseMsg) {
t.Errorf("LastError = %q, want 含 %q", got.LastError, fuseMsg)
}
if !fused && strings.Contains(got.LastError, "任务已熔断停止") {
t.Errorf("LastError = %q, 不应出现熔断说明", got.LastError)
}
}
// TestNotifyEventsFailedTransitions 锁定熔断相关通知语义:置 failed 当次
// 只发任务停止不叠加任务失败;重新启用后恢复只发任务恢复。
func TestNotifyEventsFailedTransitions(t *testing.T) {
fuseErr := "连续 3 次 NotAuthenticated,任务已熔断停止: NotAuthenticated"
tests := []struct {
name string
prev, cur taskSnapshot
wantKinds []notifyKind
wantText string // 首条通知变量值需包含的关键词;空表示不校验
}{
{
name: "熔断翻转只发任务停止不叠加任务失败",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr},
wantKinds: []notifyKind{notifyTaskStop},
wantText: "任务已熔断停止",
},
{
name: "持续 failed 不重复发",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr},
wantKinds: nil,
},
{
name: "failed 重新启用后成功只发任务恢复",
prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: fuseErr},
cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive},
wantKinds: []notifyKind{notifyTaskRecover},
wantText: "抢机",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
events := notifyEvents(tt.prev, tt.cur)
if len(events) != len(tt.wantKinds) {
t.Fatalf("events = %+v (%d 条), want %v", events, len(events), tt.wantKinds)
}
for i, ev := range events {
if ev.Kind != tt.wantKinds[i] {
t.Errorf("kind[%d] = %s, want %s", i, ev.Kind, tt.wantKinds[i])
}
}
if tt.wantText != "" {
joined := ""
for _, v := range events[0].Vars {
joined += v + " "
}
if !strings.Contains(joined, tt.wantText) {
t.Errorf("vars = %q, want contains %q", joined, tt.wantText)
}
}
})
}
}
func TestUpdateTaskFailedToActiveResetsAuthFail(t *testing.T) {
notAuth := fmt.Errorf("NotAuthenticated: 鉴权失败")
task, tasks := runAuthFuseCase(t, 0, 1, []error{notAuth, notAuth, notAuth}, 3)
ctx := context.Background()
got, err := tasks.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if got.Status != model.TaskStatusFailed {
t.Fatalf("前置熔断未生效: status=%s", got.Status)
}
active := model.TaskStatusActive
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active})
if err != nil {
t.Fatalf("重新启用: %v", err)
}
if updated.Status != model.TaskStatusActive {
t.Errorf("status = %s, want active", updated.Status)
}
if cnt := snatchAuthCount(t, updated.Payload); cnt != 0 {
t.Errorf("authFailCount = %d, want 0(重新启用应清零)", cnt)
}
if _, ok := tasks.entries[task.ID]; !ok {
t.Error("重新启用后任务未回到调度")
}
}
+192
View File
@@ -0,0 +1,192 @@
package service
import (
"context"
"fmt"
"strings"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// homeRegionOf 返回配置的 home region 名称;本地表查不到时退回默认区域。
// 用户管理等 IAM 写操作必须发往 home region。
func homeRegionOf(cfg *model.OciConfig) string {
if info, ok := oci.RegionByKey(cfg.HomeRegionKey); ok {
return info.Name
}
return cfg.Region
}
// credentialsAndHomeRegion 一次取回凭据与 home region。
func (s *OciConfigService) credentialsAndHomeRegion(ctx context.Context, id uint) (oci.Credentials, string, error) {
cfg, err := s.Get(ctx, id)
if err != nil {
return oci.Credentials{}, "", err
}
cred, err := s.credentialsOf(cfg)
if err != nil {
return oci.Credentials{}, "", err
}
return cred, homeRegionOf(cfg), nil
}
// TenantUsers 列出租户 IAM 用户。
func (s *OciConfigService) TenantUsers(ctx context.Context, id uint) ([]oci.TenantUser, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListTenantUsers(ctx, cred)
}
// TenantUserDetail 查用户的域档案与管理员状态(编辑表单预填充用)。
func (s *OciConfigService) TenantUserDetail(ctx context.Context, id uint, userID string) (oci.TenantUserDetail, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.TenantUserDetail{}, err
}
return s.client.GetTenantUserDetail(ctx, cred, homeRegion, userID)
}
// CreateTenantUser 新增用户;名字与姓氏必填(域用户档案要求),
// 描述缺省用「名 姓」。
func (s *OciConfigService) CreateTenantUser(ctx context.Context, id uint, in oci.CreateTenantUserInput) (oci.TenantUser, error) {
if strings.TrimSpace(in.Name) == "" {
return oci.TenantUser{}, fmt.Errorf("create tenant user: name is required")
}
if strings.TrimSpace(in.GivenName) == "" || strings.TrimSpace(in.FamilyName) == "" {
return oci.TenantUser{}, fmt.Errorf("create tenant user: givenName and familyName are required")
}
if in.Description == "" {
in.Description = strings.TrimSpace(in.GivenName + " " + in.FamilyName)
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.TenantUser{}, err
}
return s.client.CreateTenantUser(ctx, cred, homeRegion, in)
}
// UpdateTenantUser 编辑用户资料;nil 字段不修改,全 nil 报错。
func (s *OciConfigService) UpdateTenantUser(ctx context.Context, id uint, userID string, in oci.UpdateTenantUserInput) (oci.TenantUser, error) {
hasField := in.Description != nil || in.Email != nil || in.GivenName != nil || in.FamilyName != nil
hasAdmin := in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil
if !hasField && !hasAdmin {
return oci.TenantUser{}, fmt.Errorf("update tenant user: nothing to update")
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.TenantUser{}, err
}
return s.client.UpdateTenantUser(ctx, cred, homeRegion, userID, in)
}
// IdentitySetting 读取域身份设置(当前只透出主邮箱必填开关)。
func (s *OciConfigService) IdentitySetting(ctx context.Context, id uint) (oci.IdentitySettingInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentitySettingInfo{}, err
}
return s.client.GetIdentitySetting(ctx, cred, homeRegion)
}
// UpdateIdentitySetting 修改「用户需要提供主电子邮件地址」开关。
func (s *OciConfigService) UpdateIdentitySetting(ctx context.Context, id uint, primaryEmailRequired bool) (oci.IdentitySettingInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentitySettingInfo{}, err
}
return s.client.UpdateIdentitySetting(ctx, cred, homeRegion, primaryEmailRequired)
}
// DeleteTenantUser 删除 IAM 用户;拒绝删除当前配置正在使用的用户。
func (s *OciConfigService) DeleteTenantUser(ctx context.Context, id uint, userID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
if userID == cred.UserOCID {
return fmt.Errorf("delete tenant user: refusing to delete the user this config signs requests with")
}
return s.client.DeleteTenantUser(ctx, cred, homeRegion, userID)
}
// ResetTenantUserPassword 重置用户控制台密码,返回一次性新密码。
func (s *OciConfigService) ResetTenantUserPassword(ctx context.Context, id uint, userID string) (string, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return "", err
}
return s.client.ResetTenantUserPassword(ctx, cred, homeRegion, userID)
}
// DeleteTenantUserMfa 清除用户全部 MFA,返回删除的经典 TOTP 设备数。
func (s *OciConfigService) DeleteTenantUserMfa(ctx context.Context, id uint, userID string) (int, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return 0, err
}
return s.client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, userID)
}
// DeleteTenantUserApiKeys 删除用户的 API Key;默认保留当前配置使用的指纹。
func (s *OciConfigService) DeleteTenantUserApiKeys(ctx context.Context, id uint, userID string, includeCurrent bool) (int, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return 0, err
}
return s.client.DeleteTenantUserApiKeys(ctx, cred, homeRegion, userID, includeCurrent)
}
// NotificationRecipients 查询域通知收件人设置。
func (s *OciConfigService) NotificationRecipients(ctx context.Context, id uint) (oci.NotificationRecipients, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.NotificationRecipients{}, err
}
return s.client.GetNotificationRecipients(ctx, cred, homeRegion)
}
// UpdateNotificationRecipients 把域通知改为只发给指定收件人;
// 收件人为空表示关闭 test mode 恢复默认发送。
func (s *OciConfigService) UpdateNotificationRecipients(ctx context.Context, id uint, emails []string) (oci.NotificationRecipients, error) {
if err := validateEmails(emails); err != nil {
return oci.NotificationRecipients{}, err
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.NotificationRecipients{}, err
}
return s.client.UpdateNotificationRecipients(ctx, cred, homeRegion, emails)
}
func validateEmails(emails []string) error {
for _, email := range emails {
if !strings.Contains(email, "@") || strings.ContainsAny(email, " \t") {
return fmt.Errorf("update notification recipients: invalid email %q", email)
}
}
return nil
}
// PasswordPolicies 列出域密码策略。
func (s *OciConfigService) PasswordPolicies(ctx context.Context, id uint) ([]oci.PasswordPolicyInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListPasswordPolicies(ctx, cred, homeRegion)
}
// UpdatePasswordPolicy 修改域密码策略的过期天数或最小长度。
func (s *OciConfigService) UpdatePasswordPolicy(ctx context.Context, id uint, policyID string, in oci.UpdatePasswordPolicyInput) (oci.PasswordPolicyInfo, error) {
if in.PasswordExpiresAfter == nil && in.MinLength == nil {
return oci.PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.PasswordPolicyInfo{}, err
}
return s.client.UpdatePasswordPolicy(ctx, cred, homeRegion, policyID, in)
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"oci-portal/internal/model"
)
// TOTP(RFC 6238)两步验证相关错误;api 层据此映射状态码。
var (
// ErrTotpRequired 表示账号已启用两步验证但登录未携带验证码(密码已通过)。
ErrTotpRequired = errors.New("需要两步验证码")
// ErrTotpInvalid 表示验证码校验失败(设置流程中)。
ErrTotpInvalid = errors.New("两步验证码错误")
// ErrTotpNotSetup 表示激活前未执行 setup 或暂存已过期。
ErrTotpNotSetup = errors.New("请先重新发起两步验证设置")
// ErrTotpAlreadyOn 表示重复启用。
ErrTotpAlreadyOn = errors.New("两步验证已启用,如需重置请先停用")
// ErrTotpConfirm 表示停用时未提供密码或验证码确认。
ErrTotpConfirm = errors.New("停用需要当前验证码或登录密码确认")
)
// totpPendingTTL 是 setup 暂存密钥的有效期;过期须重新 setup。
const totpPendingTTL = 10 * time.Minute
// totpIssuer 展示在验证器 App 中的签发方名称。
const totpIssuer = "oci-portal"
// pendingTotp 是 setup 后待激活的临时密钥(内存,重启丢弃)。
type pendingTotp struct {
secret string
expires time.Time
}
// TotpStatus 返回用户是否已启用两步验证。
func (s *AuthService) TotpStatus(ctx context.Context, username string) (bool, error) {
user, err := s.findUser(ctx, username)
if err != nil {
return false, err
}
return user.TotpSecretEnc != "", nil
}
// findUser 按用户名加载账号。
func (s *AuthService) findUser(ctx context.Context, username string) (*model.User, error) {
var user model.User
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
return &user, nil
}
// SetupTotp 生成新 TOTP 密钥暂存(10 分钟内待激活),返回手动输入码与 otpauth URI;
// 重复调用覆盖旧暂存,已启用时拒绝(先停用再重置)。
func (s *AuthService) SetupTotp(ctx context.Context, username string) (secret, uri string, err error) {
if s.cipher == nil {
return "", "", errors.New("totp: cipher not configured")
}
enabled, err := s.TotpStatus(ctx, username)
if err != nil {
return "", "", err
}
if enabled {
return "", "", ErrTotpAlreadyOn
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: totpIssuer, AccountName: username})
if err != nil {
return "", "", fmt.Errorf("generate totp key: %w", err)
}
s.totpMu.Lock()
s.totpPending[username] = pendingTotp{secret: key.Secret(), expires: time.Now().Add(totpPendingTTL)}
s.totpMu.Unlock()
return key.Secret(), key.URL(), nil
}
// ActivateTotp 校验暂存密钥的验证码,通过后加密落库启用。
func (s *AuthService) ActivateTotp(ctx context.Context, username, code string) error {
s.totpMu.Lock()
pending, ok := s.totpPending[username]
s.totpMu.Unlock()
if !ok || time.Now().After(pending.expires) {
return ErrTotpNotSetup
}
if !totp.Validate(code, pending.secret) {
return ErrTotpInvalid
}
enc, err := s.cipher.EncryptString(pending.secret)
if err != nil {
return fmt.Errorf("encrypt totp secret: %w", err)
}
err = s.db.WithContext(ctx).Model(&model.User{}).
Where("username = ?", username).Update("totp_secret_enc", enc).Error
if err != nil {
return fmt.Errorf("save totp secret: %w", err)
}
s.totpMu.Lock()
delete(s.totpPending, username)
s.totpMu.Unlock()
return nil
}
// DisableTotp 停用两步验证;需当前验证码或登录密码任一确认。
func (s *AuthService) DisableTotp(ctx context.Context, username, password, code string) error {
user, err := s.findUser(ctx, username)
if err != nil {
return err
}
if user.TotpSecretEnc == "" {
return nil
}
if !s.confirmDisable(user, password, code) {
return ErrTotpConfirm
}
err = s.db.WithContext(ctx).Model(&model.User{}).
Where("username = ?", username).Update("totp_secret_enc", "").Error
if err != nil {
return fmt.Errorf("clear totp secret: %w", err)
}
return nil
}
// confirmDisable 校验停用凭证:验证码或密码任一通过即可。
func (s *AuthService) confirmDisable(user *model.User, password, code string) bool {
if code != "" && s.verifyTotp(user, code) {
return true
}
if password != "" && bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil {
return true
}
return false
}
// verifyTotp 解密密钥并校验验证码(±1 时间步容差)。
func (s *AuthService) verifyTotp(user *model.User, code string) bool {
if s.cipher == nil || user.TotpSecretEnc == "" {
return false
}
secret, err := s.cipher.DecryptString(user.TotpSecretEnc)
if err != nil {
return false
}
return totp.Validate(code, secret)
}
+311
View File
@@ -0,0 +1,311 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// newTotpEnv 建含 User/UserIdentity/Setting 表的环境,预置 admin 并注入 cipher。
func newTotpEnv(t *testing.T) (*AuthService, *gorm.DB) {
t.Helper()
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.User{}, &model.UserIdentity{}, &model.Setting{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
auth := NewAuthService(db, "test-secret")
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
auth.SetCipher(cipher)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("ensure admin: %v", err)
}
return auth, db
}
// enableTotp 走完整 setup→activate 流程,返回明文密钥供测试生成验证码。
func enableTotp(t *testing.T, auth *AuthService) string {
t.Helper()
ctx := context.Background()
secret, uri, err := auth.SetupTotp(ctx, "admin")
if err != nil {
t.Fatalf("SetupTotp: %v", err)
}
if secret == "" || uri == "" {
t.Fatalf("setup 返回空 secret/uri")
}
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
if err := auth.ActivateTotp(ctx, "admin", code); err != nil {
t.Fatalf("ActivateTotp: %v", err)
}
return secret
}
func TestTotpLifecycle(t *testing.T) {
auth, db := newTotpEnv(t)
ctx := context.Background()
if on, _ := auth.TotpStatus(ctx, "admin"); on {
t.Fatal("初始不应启用")
}
secret := enableTotp(t, auth)
if on, _ := auth.TotpStatus(ctx, "admin"); !on {
t.Fatal("激活后应为启用")
}
// 密钥必须密文落库
var user model.User
if err := db.Where("username = ?", "admin").First(&user).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if user.TotpSecretEnc == secret || user.TotpSecretEnc == "" {
t.Errorf("totp 密钥未加密落库")
}
// 重复 setup 拒绝
if _, _, err := auth.SetupTotp(ctx, "admin"); !errors.Is(err, ErrTotpAlreadyOn) {
t.Errorf("重复 setup err = %v, want ErrTotpAlreadyOn", err)
}
// 停用:无凭证拒绝,密码通过
if err := auth.DisableTotp(ctx, "admin", "", ""); !errors.Is(err, ErrTotpConfirm) {
t.Errorf("空凭证停用 err = %v, want ErrTotpConfirm", err)
}
if err := auth.DisableTotp(ctx, "admin", "pass123", ""); err != nil {
t.Fatalf("密码停用: %v", err)
}
if on, _ := auth.TotpStatus(ctx, "admin"); on {
t.Fatal("停用后应为关闭")
}
}
func TestActivateTotpRejects(t *testing.T) {
auth, _ := newTotpEnv(t)
ctx := context.Background()
// 未 setup 直接激活
if err := auth.ActivateTotp(ctx, "admin", "123456"); !errors.Is(err, ErrTotpNotSetup) {
t.Errorf("err = %v, want ErrTotpNotSetup", err)
}
// setup 后错误验证码
if _, _, err := auth.SetupTotp(ctx, "admin"); err != nil {
t.Fatalf("SetupTotp: %v", err)
}
if err := auth.ActivateTotp(ctx, "admin", "000000"); !errors.Is(err, ErrTotpInvalid) {
t.Errorf("err = %v, want ErrTotpInvalid", err)
}
}
func TestLoginWithTotp(t *testing.T) {
auth, _ := newTotpEnv(t)
ctx := context.Background()
secret := enableTotp(t, auth)
// 缺验证码:密码对也返回 ErrTotpRequired(不计失败)
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrTotpRequired) {
t.Fatalf("err = %v, want ErrTotpRequired", err)
}
// 错误验证码:按失败处理
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "000000"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("err = %v, want ErrInvalidCredentials", err)
}
// 正确验证码:登录成功
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
token, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", code)
if err != nil || token == "" {
t.Fatalf("带验证码登录失败: %v", err)
}
}
// fakeBindIdentity 直接把外部身份写入待测服务(绕过真实 OAuth flow)。
func fakeBindIdentity(t *testing.T, o *OAuthService, username, provider, subject, display string) {
t.Helper()
if err := o.bind(context.Background(), username, provider, externalIdentity{Subject: subject, Display: display}); err != nil {
t.Fatalf("bind: %v", err)
}
}
func newOAuthEnv(t *testing.T) (*OAuthService, *AuthService) {
t.Helper()
auth, db := newTotpEnv(t)
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
settings := NewSettingService(db, cipher)
return NewOAuthService(db, settings, auth), auth
}
func TestOAuthBindLoginUnbind(t *testing.T) {
o, auth := newOAuthEnv(t)
ctx := context.Background()
fakeBindIdentity(t, o, "admin", "github", "10086", "octocat")
// 重复绑定同一身份拒绝
if err := o.bind(ctx, "admin", "github", externalIdentity{Subject: "10086", Display: "octocat"}); !errors.Is(err, ErrOAuthBound) {
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
}
// 已绑定身份可登录并拿到有效 JWT
token, display, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
if err != nil || token == "" {
t.Fatalf("loginByIdentity: %v", err)
}
if display != "octocat" {
t.Errorf("display = %q", display)
}
if username, err := auth.ParseToken(token); err != nil || username != "admin" {
t.Errorf("token 应属 admin, got %q (%v)", username, err)
}
// 未绑定身份拒绝登录
if _, _, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "999"}); !errors.Is(err, ErrOAuthNotBound) {
t.Errorf("未绑定登录 err = %v, want ErrOAuthNotBound", err)
}
// 列表与解绑
items, err := o.Identities(ctx, "admin")
if err != nil || len(items) != 1 {
t.Fatalf("identities = %d (%v), want 1", len(items), err)
}
if err := o.Unbind(ctx, "admin", items[0].ID); err != nil {
t.Fatalf("Unbind: %v", err)
}
if items, _ = o.Identities(ctx, "admin"); len(items) != 0 {
t.Errorf("解绑后仍有 %d 条", len(items))
}
}
func TestOAuthStateOneShot(t *testing.T) {
o, _ := newOAuthEnv(t)
o.mu.Lock()
o.pending["st1"] = oauthPending{provider: "github", mode: "login", expires: time.Now().Add(time.Minute)}
o.pending["st2"] = oauthPending{provider: "github", mode: "login", expires: time.Now().Add(-time.Minute)}
o.mu.Unlock()
if _, err := o.takeState("github", "st1"); err != nil {
t.Fatalf("首次消费: %v", err)
}
if _, err := o.takeState("github", "st1"); !errors.Is(err, ErrOAuthState) {
t.Errorf("二次消费 err = %v, want ErrOAuthState", err)
}
if _, err := o.takeState("github", "st2"); !errors.Is(err, ErrOAuthState) {
t.Errorf("过期 state err = %v, want ErrOAuthState", err)
}
if _, err := o.takeState("oidc", "no-such"); !errors.Is(err, ErrOAuthState) {
t.Errorf("未知 state err = %v, want ErrOAuthState", err)
}
}
func TestOAuthProvidersListsConfigured(t *testing.T) {
o, _ := newOAuthEnv(t)
ctx := context.Background()
if got := o.Providers(ctx); len(got) != 0 {
t.Fatalf("未配置时 providers = %v", got)
}
secret := "gh-secret"
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret})
if err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
got := o.Providers(ctx)
if len(got) != 1 || got[0].Provider != "github" {
t.Errorf("providers = %v, want [github]", got)
}
// 视图不回明文且标记已设置
view, err := o.settings.OAuthView(ctx)
if err != nil || !view.GithubSecretSet || view.GithubClientID != "cid" {
t.Errorf("view = %+v (%v)", view, err)
}
}
// 未配置 provider 与未设面板地址是两类错误,文案分别指路。
func TestOAuthAuthorizeConfigErrors(t *testing.T) {
o, _ := newOAuthEnv(t)
ctx := context.Background()
// clientID 缺失
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNotConfigured) {
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
}
// clientID 已配但面板地址未设置
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) {
t.Errorf("无面板地址 err = %v, want ErrOAuthNoAppURL", err)
}
// 面板地址就绪后正常返回授权 URL
o.settings.SetEnvPublicURL("https://demo.example.com")
url, err := o.AuthorizeURL(ctx, "github", "login", "")
if err != nil || url == "" {
t.Fatalf("AuthorizeURL: %q, %v", url, err)
}
}
func TestOAuthProvidersAndDisabled(t *testing.T) {
o, _ := newOAuthEnv(t)
ctx := context.Background()
o.settings.SetEnvPublicURL("https://demo.example.com")
// 未配置任何 provider → 空列表
if got := o.Providers(ctx); len(got) != 0 {
t.Fatalf("Providers = %v, want empty", got)
}
// 配置 github(无显示名称)→ 默认名 GitHub
in := UpdateOAuthInput{GithubClientID: "Iv1.test"}
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
got := o.Providers(ctx)
if len(got) != 1 || got[0].Provider != "github" || got[0].DisplayName != "GitHub" {
t.Fatalf("Providers = %+v, want [github/GitHub]", got)
}
// 自定义显示名称
in.GithubDisplayName = "公司账号"
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
if got = o.Providers(ctx); got[0].DisplayName != "公司账号" {
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
}
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
in.GithubDisabled = true
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
if got = o.Providers(ctx); len(got) != 0 {
t.Fatalf("禁用后 Providers = %v, want empty", got)
}
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthDisabled) {
t.Errorf("禁用 login err = %v, want ErrOAuthDisabled", err)
}
if url, err := o.AuthorizeURL(ctx, "github", "bind", "admin"); err != nil || url == "" {
t.Errorf("禁用 bind = %q, %v, want 正常返回", url, err)
}
// view 回读禁用态与显示名称
view, err := o.settings.OAuthView(ctx)
if err != nil || !view.GithubDisabled || view.GithubDisplayName != "公司账号" {
t.Fatalf("OAuthView = %+v, %v", view, err)
}
}
+91
View File
@@ -0,0 +1,91 @@
package service
import (
"context"
"fmt"
"time"
"oci-portal/internal/oci"
)
// maxUsageWindowDays 限制流量/成本查询窗口,避免拉取超大区间。
const maxUsageWindowDays = 366
// InstanceTraffic 查询实例近 days 天的进出流量(按天聚合)。
func (s *OciConfigService) InstanceTraffic(ctx context.Context, id uint, region, instanceID string, days int) (oci.InstanceTraffic, error) {
if days <= 0 {
days = 30
}
if days > maxUsageWindowDays {
return oci.InstanceTraffic{}, fmt.Errorf("instance traffic: days must be within %d", maxUsageWindowDays)
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.InstanceTraffic{}, err
}
end := time.Now().UTC().Truncate(time.Hour).Add(time.Hour)
return s.client.SummarizeInstanceTraffic(ctx, cred, oci.TrafficQuery{
Region: region,
InstanceID: instanceID,
StartTime: end.AddDate(0, 0, -days),
EndTime: end,
})
}
// Costs 汇总租户成本或用量;时间缺省为最近 30 天,起止按粒度对齐。
func (s *OciConfigService) Costs(ctx context.Context, id uint, q oci.CostQuery) ([]oci.CostItem, error) {
applyCostDefaults(&q)
if q.EndTime.Sub(q.StartTime) <= 0 {
return nil, fmt.Errorf("costs: endTime must be after startTime")
}
if q.EndTime.Sub(q.StartTime) > maxUsageWindowDays*24*time.Hour {
return nil, fmt.Errorf("costs: window must be within %d days", maxUsageWindowDays)
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.SummarizeCosts(ctx, cred, q)
}
// applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。
func applyCostDefaults(q *oci.CostQuery) {
if q.Granularity == "" {
q.Granularity = "DAILY"
}
if q.QueryType == "" {
q.QueryType = "COST"
}
if q.GroupBy == "" {
q.GroupBy = "service"
}
now := time.Now().UTC()
if q.EndTime.IsZero() {
q.EndTime = now
}
if q.StartTime.IsZero() {
q.StartTime = q.EndTime.AddDate(0, 0, -30)
}
q.StartTime = alignDown(q.StartTime, q.Granularity)
// endTime 为开区间,向上对齐到粒度边界以覆盖当天/当月
q.EndTime = alignUp(q.EndTime, q.Granularity)
}
func alignDown(t time.Time, granularity string) time.Time {
t = t.UTC()
if granularity == "MONTHLY" {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
}
return t.Truncate(24 * time.Hour)
}
func alignUp(t time.Time, granularity string) time.Time {
down := alignDown(t, granularity)
if down.Equal(t.UTC()) {
return down
}
if granularity == "MONTHLY" {
return down.AddDate(0, 1, 0)
}
return down.AddDate(0, 0, 1)
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"testing"
"time"
"oci-portal/internal/oci"
)
func TestAlignDown(t *testing.T) {
tests := []struct {
name string
in time.Time
granularity string
want time.Time
}{
{
name: "daily 截到当天零点",
in: time.Date(2026, 7, 3, 15, 4, 5, 0, time.UTC),
granularity: "DAILY",
want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC),
},
{
name: "daily 已在边界不变",
in: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC),
granularity: "DAILY",
want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC),
},
{
name: "monthly 截到当月一号",
in: time.Date(2026, 7, 15, 8, 0, 0, 0, time.UTC),
granularity: "MONTHLY",
want: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := alignDown(tt.in, tt.granularity); !got.Equal(tt.want) {
t.Errorf("alignDown() = %v, want %v", got, tt.want)
}
})
}
}
func TestAlignUp(t *testing.T) {
tests := []struct {
name string
in time.Time
granularity string
want time.Time
}{
{
name: "daily 进到明日零点覆盖当天",
in: time.Date(2026, 7, 3, 15, 4, 5, 0, time.UTC),
granularity: "DAILY",
want: time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC),
},
{
name: "daily 已在边界不进位",
in: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC),
granularity: "DAILY",
want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC),
},
{
name: "monthly 进到下月一号覆盖当月",
in: time.Date(2026, 7, 15, 8, 0, 0, 0, time.UTC),
granularity: "MONTHLY",
want: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "monthly 十二月进位跨年",
in: time.Date(2026, 12, 20, 0, 0, 0, 0, time.UTC),
granularity: "MONTHLY",
want: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := alignUp(tt.in, tt.granularity); !got.Equal(tt.want) {
t.Errorf("alignUp() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplyCostDefaults(t *testing.T) {
var q oci.CostQuery
applyCostDefaults(&q)
if q.Granularity != "DAILY" || q.QueryType != "COST" || q.GroupBy != "service" {
t.Errorf("defaults = %s/%s/%s, want DAILY/COST/service", q.Granularity, q.QueryType, q.GroupBy)
}
if q.StartTime.IsZero() || q.EndTime.IsZero() {
t.Fatal("time window not defaulted")
}
window := q.EndTime.Sub(q.StartTime)
if window < 30*24*time.Hour || window > 32*24*time.Hour {
t.Errorf("default window = %v, want ~30d", window)
}
if !q.EndTime.After(time.Now().UTC()) {
t.Errorf("endTime %v should be aligned up past now (open interval)", q.EndTime)
}
}
func TestValidateEmails(t *testing.T) {
tests := []struct {
name string
emails []string
wantErr bool
}{
{name: "空列表合法表示关闭 test mode", emails: nil, wantErr: false},
{name: "正常邮箱", emails: []string{"a@b.com", "c@d.org"}, wantErr: false},
{name: "缺少@", emails: []string{"not-an-email"}, wantErr: true},
{name: "含空格", emails: []string{"a b@c.com"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateEmails(tt.emails); (err != nil) != tt.wantErr {
t.Errorf("validateEmails(%v) error = %v, wantErr %v", tt.emails, err, tt.wantErr)
}
})
}
}
+75
View File
@@ -0,0 +1,75 @@
package service
import (
"context"
"strings"
"testing"
"oci-portal/internal/oci"
)
// vnicStubClient 覆写 VNIC 三方法并记录透传参数,其余行为沿用 fakeClient。
type vnicStubClient struct {
*fakeClient
vnics []oci.Vnic
attached oci.AttachVnicInput
attachInst string
detachedID string
ipv6Vnic string
}
func (f *vnicStubClient) ListInstanceVnics(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.Vnic, error) {
return f.vnics, nil
}
func (f *vnicStubClient) AttachVnic(ctx context.Context, cred oci.Credentials, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
f.attachInst, f.attached = instanceID, in
return oci.Vnic{AttachmentID: "att-1", SubnetID: in.SubnetID, LifecycleState: "ATTACHING"}, nil
}
func (f *vnicStubClient) DetachVnic(ctx context.Context, cred oci.Credentials, region, attachmentID string) error {
f.detachedID = attachmentID
return nil
}
func (f *vnicStubClient) AddVnicIpv6(ctx context.Context, cred oci.Credentials, region, vnicID, address string) (string, error) {
f.ipv6Vnic = vnicID
return "2603:c022::1", nil
}
func TestVnicManagement(t *testing.T) {
client := &vnicStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
vnics: []oci.Vnic{{AttachmentID: "att-0", IsPrimary: true, PrivateIP: "10.0.0.2"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
got, err := svc.InstanceVnics(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x")
if err != nil || len(got) != 1 || !got[0].IsPrimary {
t.Fatalf("InstanceVnics = %+v, %v", got, err)
}
// subnetId 缺失被拒
_, err = svc.AttachVnic(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", oci.AttachVnicInput{})
if err == nil || !strings.Contains(err.Error(), "subnetId") {
t.Errorf("AttachVnic 无 subnet err = %v, want subnetId required", err)
}
in := oci.AttachVnicInput{SubnetID: "ocid1.subnet.oc1..s", AssignPublicIP: true, DisplayName: "vnic-2"}
vnic, err := svc.AttachVnic(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", in)
if err != nil || vnic.AttachmentID != "att-1" || client.attached != in || client.attachInst != "ocid1.instance.oc1..x" {
t.Fatalf("AttachVnic = %+v, %v (passed %+v)", vnic, err, client.attached)
}
if err := svc.DetachVnic(ctx, cfg.ID, "ap-tokyo-1", "att-1"); err != nil || client.detachedID != "att-1" {
t.Fatalf("DetachVnic err = %v, detached = %q", err, client.detachedID)
}
// per-VNIC IPv6:vnicId 缺失被拒,正常路径透传
if _, err := svc.AddVnicIpv6(ctx, cfg.ID, "ap-tokyo-1", "", ""); err == nil {
t.Error("AddVnicIpv6 空 vnicId 应报错")
}
addr, err := svc.AddVnicIpv6(ctx, cfg.ID, "ap-tokyo-1", "ocid1.vnic.oc1..v", "")
if err != nil || addr == "" || client.ipv6Vnic != "ocid1.vnic.oc1..v" {
t.Fatalf("AddVnicIpv6 = %q, %v (vnic %q)", addr, err, client.ipv6Vnic)
}
}
+353
View File
@@ -0,0 +1,353 @@
package service
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/hex"
"fmt"
"io"
"net"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// consoleSessionTTL 是会话从创建起的最长存活时间,超时未在用即回收(含云端连接)。
// OCI 侧另有 24 小时强制断开兜底。
const consoleSessionTTL = 15 * time.Minute
// 网页控制台会话类型:serial 走 PTY 交互式会话,vnc 走 5900 端口转发。
const (
ConsoleTypeSerial = "serial"
ConsoleTypeVnc = "vnc"
)
// ConsoleSession 是一次网页控制台会话(串行 / VNC):私钥只存内存,服务重启即失效。
type ConsoleSession struct {
ID string
Type string
cfgID uint
instanceID string
region string
connectionID string // console connection OCID,同时是第一跳 SSH 的用户名
consoleHost string // instance-console.<region>.oci.oraclecloud.com
signer ssh.Signer
createdAt time.Time
mu sync.Mutex
inUse bool
}
// ConsoleService 管理网页控制台会话:创建 OCI 控制台连接、建立两跳 SSH 隧道。
type ConsoleService struct {
configs *OciConfigService
pollInterval time.Duration // 清理残留连接的轮询间隔,测试注入缩短
mu sync.Mutex
sessions map[string]*ConsoleSession
}
func NewConsoleService(configs *OciConfigService) *ConsoleService {
return &ConsoleService{
configs: configs,
pollInterval: 2 * time.Second,
sessions: map[string]*ConsoleSession{},
}
}
// CreateSession 生成一次性 RSA 密钥对并创建实例控制台连接(等待 ACTIVE)。
// OCI 限制每实例一个控制台连接,创建前先清理实例上的残留连接。
func (s *ConsoleService) CreateSession(ctx context.Context, cfgID uint, instanceID, region, typ string) (*ConsoleSession, error) {
if typ != ConsoleTypeSerial && typ != ConsoleTypeVnc {
return nil, fmt.Errorf("unsupported console session type %q (expect serial or vnc)", typ)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate rsa key: %w", err)
}
signer, err := ssh.NewSignerFromKey(key)
if err != nil {
return nil, fmt.Errorf("new ssh signer: %w", err)
}
if err := s.cleanupStaleConnections(ctx, cfgID, region, instanceID); err != nil {
return nil, err
}
pubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
conn, err := s.configs.CreateConsoleConnection(ctx, cfgID, region, instanceID, pubKey)
if err != nil {
return nil, err
}
if conn.LifecycleState != "ACTIVE" {
return nil, fmt.Errorf("console connection not active yet (state %s), retry later", conn.LifecycleState)
}
host, err := parseConsoleHost(conn.VncConnectionString)
if err != nil {
return nil, err
}
return s.storeSession(cfgID, instanceID, region, typ, conn.ID, host, signer), nil
}
// cleanupStaleConnections 删除实例上已有的控制台连接(每实例限一个),
// 并等待其真正消失;DELETED 是终态记录,仅需跳过。
func (s *ConsoleService) cleanupStaleConnections(ctx context.Context, cfgID uint, region, instanceID string) error {
conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID)
if err != nil {
return fmt.Errorf("list existing console connections: %w", err)
}
pending := false
for _, c := range conns {
switch c.LifecycleState {
case "DELETED":
continue
case "DELETING":
pending = true
default:
if err := s.configs.DeleteConsoleConnection(ctx, cfgID, region, c.ID); err != nil {
return fmt.Errorf("delete stale console connection: %w", err)
}
pending = true
}
}
if !pending {
return nil
}
return s.waitConnectionsGone(ctx, cfgID, region, instanceID)
}
// waitConnectionsGone 轮询等待实例上的控制台连接全部进入 DELETED,最长约 60 秒。
func (s *ConsoleService) waitConnectionsGone(ctx context.Context, cfgID uint, region, instanceID string) error {
for i := 0; i < 30; i++ {
select {
case <-ctx.Done():
return fmt.Errorf("wait console connection cleanup: %w", ctx.Err())
case <-time.After(s.pollInterval):
}
conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID)
if err != nil {
return fmt.Errorf("poll console connection cleanup: %w", err)
}
gone := true
for _, c := range conns {
if c.LifecycleState != "DELETED" {
gone = false
break
}
}
if gone {
return nil
}
}
return fmt.Errorf("cleanup previous console connection timed out, retry later")
}
func (s *ConsoleService) storeSession(cfgID uint, instanceID, region, typ, connID, host string, signer ssh.Signer) *ConsoleSession {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
sess := &ConsoleSession{
ID: hex.EncodeToString(buf),
Type: typ,
cfgID: cfgID,
instanceID: instanceID,
region: region,
connectionID: connID,
consoleHost: host,
signer: signer,
createdAt: time.Now(),
}
s.mu.Lock()
s.sessions[sess.ID] = sess
s.mu.Unlock()
time.AfterFunc(consoleSessionTTL, func() { s.expire(sess.ID) })
return sess
}
// expire TTL 到期回收:正在使用的会话跳过(连接断开后自然停止,无续期)。
func (s *ConsoleService) expire(id string) {
s.mu.Lock()
sess, ok := s.sessions[id]
if ok {
sess.mu.Lock()
if sess.inUse {
ok = false
} else {
delete(s.sessions, id)
}
sess.mu.Unlock()
}
s.mu.Unlock()
if ok {
s.deleteCloudConnection(sess)
}
}
// Get 返回会话;不存在或已回收返回 nil。
func (s *ConsoleService) Get(id string) *ConsoleSession {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessions[id]
}
// CloseSession 主动结束会话并删除云端控制台连接。
func (s *ConsoleService) CloseSession(id string) {
s.mu.Lock()
sess, ok := s.sessions[id]
delete(s.sessions, id)
s.mu.Unlock()
if ok {
s.deleteCloudConnection(sess)
}
}
func (s *ConsoleService) deleteCloudConnection(sess *ConsoleSession) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = s.configs.DeleteConsoleConnection(ctx, sess.cfgID, sess.region, sess.connectionID)
}
// MarkUse 标记会话进入 / 退出使用(使用中的会话不被 TTL 回收)。
func (sess *ConsoleSession) MarkUse(v bool) {
sess.mu.Lock()
sess.inUse = v
sess.mu.Unlock()
}
// consoleHostRe 从 OCI 下发的 VNC 连接串里取代理主机:
// ssh -o ProxyCommand='ssh -W %h:%p -p 443 <conn_ocid>@<host>' ...
var consoleHostRe = regexp.MustCompile(`-p\s+443\s+\S+@([A-Za-z0-9.-]+)`)
func parseConsoleHost(vncConnectionString string) (string, error) {
m := consoleHostRe.FindStringSubmatch(vncConnectionString)
if m == nil {
return "", fmt.Errorf("parse console host from vnc connection string failed")
}
return m[1], nil
}
// sshConfig 构造两跳共用的客户端配置;代理主机名来自 OCI API 响应,无公开指纹可校验。
func (sess *ConsoleSession) sshConfig(user string) *ssh.ClientConfig {
return &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(sess.signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 20 * time.Second,
}
}
// dialHops 建立两跳 SSH:第一跳到区域控制台代理(443,用户名为连接 OCID),
// 在其上开通道到实例 22 端口完成第二跳握手,返回第二跳客户端。
func (sess *ConsoleSession) dialHops() (hop1, hop2 *ssh.Client, err error) {
hop1, err = ssh.Dial("tcp", net.JoinHostPort(sess.consoleHost, "443"), sess.sshConfig(sess.connectionID))
if err != nil {
return nil, nil, fmt.Errorf("dial console proxy: %w", err)
}
pipe, err := hop1.Dial("tcp", net.JoinHostPort(sess.instanceID, "22"))
if err != nil {
hop1.Close()
return nil, nil, fmt.Errorf("open tunnel to instance console endpoint: %w", err)
}
conn, chans, reqs, err := ssh.NewClientConn(pipe, sess.instanceID+":22", sess.sshConfig(sess.instanceID))
if err != nil {
pipe.Close()
hop1.Close()
return nil, nil, fmt.Errorf("second hop ssh handshake: %w", err)
}
return hop1, ssh.NewClient(conn, chans, reqs), nil
}
// DialVNC 经第二跳转发到实例 5900 的 VNC 流。
func (sess *ConsoleSession) DialVNC(ctx context.Context) (net.Conn, error) {
hop1, hop2, err := sess.dialHops()
if err != nil {
return nil, err
}
vnc, err := hop2.Dial("tcp", net.JoinHostPort(sess.instanceID, "5900"))
if err != nil {
hop2.Close()
hop1.Close()
return nil, fmt.Errorf("open vnc channel: %w", err)
}
return &chainedConn{Conn: vnc, closers: []func() error{hop2.Close, hop1.Close}}, nil
}
// SerialTerminal 是一路串行控制台终端:读端为串口输出,写端为键入;
// Resize 调整代理侧 PTY 尺寸(guest 内 tty 需另行 stty 同步)。
type SerialTerminal struct {
io.Reader
io.Writer
session *ssh.Session
closers []func() error
}
func (t *SerialTerminal) Resize(rows, cols int) error {
return t.session.WindowChange(rows, cols)
}
func (t *SerialTerminal) Close() error {
err := t.session.Close()
for _, fn := range t.closers {
_ = fn()
}
return err
}
// OpenSerial 经第二跳打开交互式 PTY 会话,会话的输入输出即串口字节流。
func (sess *ConsoleSession) OpenSerial(ctx context.Context, rows, cols int) (*SerialTerminal, error) {
hop1, hop2, err := sess.dialHops()
if err != nil {
return nil, err
}
term, err := openSerialSession(hop2, rows, cols)
if err != nil {
hop2.Close()
hop1.Close()
return nil, err
}
term.closers = []func() error{hop2.Close, hop1.Close}
return term, nil
}
// openSerialSession 在第二跳上申请 PTY 并启动 shell(即串口流)。
func openSerialSession(hop2 *ssh.Client, rows, cols int) (*SerialTerminal, error) {
session, err := hop2.NewSession()
if err != nil {
return nil, fmt.Errorf("open serial session: %w", err)
}
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
session.Close()
return nil, fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
session.Close()
return nil, fmt.Errorf("serial stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
session.Close()
return nil, fmt.Errorf("serial stdout pipe: %w", err)
}
if err := session.Shell(); err != nil {
session.Close()
return nil, fmt.Errorf("start serial shell: %w", err)
}
return &SerialTerminal{Reader: stdout, Writer: stdin, session: session}, nil
}
// chainedConn 关闭数据流时连带关闭底层 SSH 连接,避免隧道泄漏。
type chainedConn struct {
net.Conn
closers []func() error
}
func (c *chainedConn) Close() error {
err := c.Conn.Close()
for _, fn := range c.closers {
_ = fn()
}
return err
}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"context"
"testing"
"time"
"oci-portal/internal/oci"
)
// consoleFakeClient 在 fakeClient 上覆写控制台连接三方法:
// 首次 List 返回预置连接,删除后的后续 List 全部报告 DELETED。
type consoleFakeClient struct {
fakeClient
conns []oci.ConsoleConnection
listed int
deleted []string
created int
}
func (f *consoleFakeClient) ListConsoleConnections(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.ConsoleConnection, error) {
f.listed++
if f.listed == 1 {
return f.conns, nil
}
gone := make([]oci.ConsoleConnection, len(f.conns))
for i, c := range f.conns {
c.LifecycleState = "DELETED"
gone[i] = c
}
return gone, nil
}
func (f *consoleFakeClient) DeleteConsoleConnection(ctx context.Context, cred oci.Credentials, region, connectionID string) error {
f.deleted = append(f.deleted, connectionID)
return nil
}
func (f *consoleFakeClient) CreateConsoleConnection(ctx context.Context, cred oci.Credentials, region, instanceID, sshPublicKey string) (oci.ConsoleConnection, error) {
f.created++
return oci.ConsoleConnection{
ID: "ocid1.icc.new",
InstanceID: instanceID,
LifecycleState: "ACTIVE",
VncConnectionString: "ssh -o ProxyCommand='ssh -W %h:%p -p 443 " +
"ocid1.icc.new@instance-console.eu-frankfurt-1.oci.oraclecloud.com' -N -L ...",
}, nil
}
func newConsoleTestService(t *testing.T, client *consoleFakeClient) (*ConsoleService, uint) {
t.Helper()
client.tenancy = oci.TenancyInfo{Name: "t"}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("import config: %v", err)
}
console := NewConsoleService(svc)
console.pollInterval = time.Millisecond
return console, cfg.ID
}
func TestCreateConsoleSession(t *testing.T) {
tests := []struct {
name string
typ string
conns []oci.ConsoleConnection
wantErr bool
wantDeleted int
}{
{name: "非法类型拒绝", typ: "rdp", wantErr: true},
{name: "无残留直接创建", typ: ConsoleTypeSerial},
{
name: "ACTIVE 残留先删再建",
typ: ConsoleTypeVnc,
conns: []oci.ConsoleConnection{
{ID: "ocid1.icc.stale", LifecycleState: "ACTIVE"},
},
wantDeleted: 1,
},
{
name: "DELETED 终态记录跳过",
typ: ConsoleTypeSerial,
conns: []oci.ConsoleConnection{
{ID: "ocid1.icc.gone", LifecycleState: "DELETED"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &consoleFakeClient{conns: tt.conns}
console, cfgID := newConsoleTestService(t, client)
sess, err := console.CreateSession(context.Background(), cfgID, "ocid1.instance.i", "", tt.typ)
if tt.wantErr {
if err == nil {
t.Fatal("expect error, got nil")
}
return
}
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if sess.Type != tt.typ {
t.Errorf("session type = %s, want %s", sess.Type, tt.typ)
}
if len(client.deleted) != tt.wantDeleted {
t.Errorf("deleted %d stale connections, want %d", len(client.deleted), tt.wantDeleted)
}
if client.created != 1 {
t.Errorf("created %d connections, want 1", client.created)
}
if console.Get(sess.ID) == nil {
t.Error("session not retrievable after create")
}
})
}
}