初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()}}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package service 实现业务逻辑和本地快照同步。
|
||||
package service
|
||||
@@ -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) URL,data 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 列出 VCN;compartmentID 为空表示租户根。
|
||||
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 创建 VCN;in.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)
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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 服务的 Notifier;in 为 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 <b>&"; 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<1></code>", "<i>注意</i>", "<pre>raw & 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("超长模板应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®ions)
|
||||
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 3(diff 后跟进)", 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("重新启用后任务未回到调度")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user