diff --git a/.trellis/.version b/.trellis/.version index e0ea44c..bf21f52 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.6.5 \ No newline at end of file +0.6.6 \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md index 5f70366..7775a93 100644 --- a/.trellis/workflow.md +++ b/.trellis/workflow.md @@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni - 1.0 Create task `[required · once]` (only after task-creation consent) - 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`) - 1.2 Research `[optional · repeatable]` -- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip) +- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip) - 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress) - 1.5 Completion criteria @@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first. When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed. -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] - Planning or unclear requirements -> `trellis-brainstorm`. - `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`. - Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] [codex-inline, Kilo, Antigravity, Devin] @@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc. -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] Spawn the research sub-agent: @@ -361,7 +361,7 @@ Spawn the research sub-agent: - **Task description**: Research - **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] [codex-inline, Kilo, Antigravity, Devin] @@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical #### 1.3 Configure context `[required · once]` -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries. @@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r Skip this step only when both files already have real curated entries. -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] [codex-inline, Kilo, Antigravity, Devin] @@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h | `design.md` exists (complex tasks) | ✅ | | `implement.md` exists (complex tasks) | ✅ | -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] | `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ | -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] --- @@ -472,7 +472,7 @@ Goal: turn reviewed planning artifacts into code that passes quality checks. #### 2.1 Implement `[required · repeatable]` -[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi] +[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi] Spawn the implement sub-agent: @@ -484,7 +484,7 @@ The platform hook/plugin auto-handles: - Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt - Injects `prd.md`, `design.md` if present, and `implement.md` if present -[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi] +[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi] [codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae] @@ -526,7 +526,7 @@ The platform prelude auto-handles the context load requirement: #### 2.2 Quality check `[required · repeatable]` -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] Spawn the check sub-agent: @@ -540,7 +540,7 @@ The check agent's job: - Auto-fix issues it finds - Run lint and typecheck to verify -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae] +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae] [codex-inline, Kilo, Antigravity, Devin] diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ae380..0f4ba8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。 +## [0.3.0] - 2026-07-13 + +### Added + +- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志 +- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界 + +### Changed + +- 云端关键事件通知补充操作者、来源 IP、执行结果和说明信息;支持从 OCI Audit 与 IDCS 登录事件提取成功 / 失败状态、策略描述和登录失败原因,缺失字段统一显示占位符 + +### Removed + +- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules` 与 `/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行 + ## [0.2.0] - 2026-07-12 ### Added diff --git a/DASH_VERSION b/DASH_VERSION index 1474d00..268b033 100644 --- a/DASH_VERSION +++ b/DASH_VERSION @@ -1 +1 @@ -v0.2.0 +v0.3.0 diff --git a/README.md b/README.md index decc223..ac84aee 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,21 @@ # OCI Portal -**Oracle Cloud Infrastructure 多租户管理面板** +**自托管的 OCI 多租户管理面板与 GenAI 兼容网关** -![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white) -![License](https://img.shields.io/badge/License-MIT-blue) +![Release](https://img.shields.io/github/v/release/wangdefaa/oci-portal?display_name=tag) +![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8?logo=go&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-blue) -本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件) +[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发) +OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。 + +本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。 + ## 界面预览 | 总览 | 登录 | @@ -28,70 +33,137 @@ | --- | --- | | ![AI 网关](docs/assets/screenshot-ai-gateway.png) | ![通知设置](docs/assets/screenshot-settings-notify.png) | -## 特性 +## 核心能力 -- **多租户管理**:多份 OCI API Key 配置集中管理,私钥/口令 AES-256-GCM 加密落库;分组、批量测活、账户类型与订阅信息识别 -- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额与成本查询,多区域/多区间支持 -- **抢机任务**:cron 周期尝试创建实例直到成功,支持熔断与通知 -- **网页控制台**:实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道 -- **AI 网关**:OpenAI Responses / Anthropic Messages / Embeddings 兼容端点转发 OCI GenAI,号池渠道加权负载均衡、熔断探测、密钥管理与用量日志;对话统一走 OCI OpenAI 兼容面(xai / meta / openai 厂商模型,流式事件与推理增量原样保真),支持 xAI Grok 服务端工具 `web_search` / `x_search`(格式同 xAI 官方 Agent Tools,仅非流式)——兼容面属实测可用但未见 Oracle 文档承诺的能力,行为可能随上游调整 -- **日志回传**:OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路;自定义告警规则(事件类型/来源 IP 白名单/资源/频率阈值)命中即推送 -- **租户治理**:IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人;多 Identity Domain 租户可按域切换管理 -- **安全**:JWT + bcrypt(凭据变更旧令牌立即失效,可一键撤销全部会话)、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、请求体/超时防护、系统操作审计 -- **通知**:模板化推送,五渠道并存(Telegram / Webhook / ntfy / Bark / SMTP),Webhook 可对接飞书、钉钉、Slack、企业微信机器人 +- **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥和口令使用 AES-256-GCM 加密落库 +- **计算、网络与存储**:实例创建与电源操作、公网 IP、IPv6、VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额和成本查询 +- **自动化任务**:抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知 +- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道 +- **身份与审计**:IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送 +- **通知与安全**:Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计 +- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志 + +## 运行形态 + +```text +浏览器 / API 客户端 + │ + ▼ +┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐ +│ Vue 3 静态资源(go:embed) │ +│ /api/v1 → Gin → Service → OCI SDK │ +│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │ +│ GORM → SQLite(默认)/ MySQL / PostgreSQL(experimental) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。 ## 快速开始 +### Docker Compose(推荐) + +前置条件:Docker、Docker Compose v2、OpenSSL。 + +1. 克隆仓库并生成一份需要长期保存的 `.env`: + + ```bash + git clone https://github.com/wangdefaa/oci-portal.git + cd oci-portal + + umask 077 + printf 'DATA_KEY=%s\nJWT_SECRET=%s\nADMIN_PASSWORD=%s\n' \ + "$(openssl rand -hex 32)" \ + "$(openssl rand -hex 32)" \ + "$(openssl rand -base64 24)" > .env + chmod 600 .env + ``` + +2. 准备数据目录并启动: + + ```bash + mkdir -p data + + # Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。 + sudo chown 65532:65532 data + + docker compose up -d + docker compose ps + ``` + +3. 查看初始管理员密码并登录: + + ```bash + grep '^ADMIN_PASSWORD=' .env + ``` + + 访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。 + +> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。 + ### 二进制运行 -从 Release 下载对应架构的二进制后: +Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64 主机将文件名中的 `amd64` 替换为 `arm64`: ```bash -DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> ./oci-portal-server +curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64 +chmod +x oci-portal-server-linux-amd64 + +# 复用上文生成并妥善保存的 .env。 +set -a +. ./.env +set +a + +./oci-portal-server-linux-amd64 ``` -访问 `http://localhost:8080`,用 `admin` 与初始密码登录。 +默认访问地址为 `http://localhost:8080`。`ADMIN_PASSWORD` 只在数据库中没有用户时创建初始管理员,后续启动不会用它重置密码。 -### Docker Compose +### 源码构建 + +源码构建要求 Go 1.26.5、`curl`、`unzip` 和 `sha256sum`。仓库只保留前端占位页,编译完整单文件前应下载 `DASH_VERSION` 指定的前端产物并校验: ```bash -# 镜像以 distroless nonroot(uid 65532)运行,数据卷需可写,否则 SQLite 报 unable to open database file -mkdir -p ./data && sudo chown 65532:65532 ./data -DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d +DASH_TAG="$(tr -d '\r\n' < DASH_VERSION)" +DASH_BASE="https://github.com/wangdefaa/oci-portal-dash/releases/download/${DASH_TAG}" + +curl -fL -o dist.zip "${DASH_BASE}/dist.zip" +curl -fL -o dist.zip.sha256 "${DASH_BASE}/dist.zip.sha256" +sha256sum -c dist.zip.sha256 + +rm -rf internal/webui/dist +mkdir -p internal/webui/dist +unzip -q dist.zip -d internal/webui/dist + +CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server ``` -默认只监听 `127.0.0.1:18888`;公网访问须置于 TLS 反向代理之后,见下文「反向代理」。 +本地构建显示 `dev` 版本;Release 工作流会注入正式版本和构建时间。`internal/webui/dist` 中的真实前端产物不应提交到仓库。 -### 源码构建(单文件,含前端) +## 生产部署 -```bash -# 1. 获取前端产物:从前端仓库 Release 下载 dist.zip(或本地 npm run build 后拷入) -curl -fL -o dist.zip https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip -rm -rf internal/webui/dist && mkdir -p internal/webui/dist && unzip -q dist.zip -d internal/webui/dist -# 2. 编译(免 CGO,可交叉编译;-X 两项注入「设置·关于」页的版本与构建时间,可省略,省略则显示 dev) -CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X oci-portal/internal/api.buildVersion=v0.0.1 -X oci-portal/internal/api.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o bin/oci-portal-server ./cmd/server -``` +服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。 -> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。 - -### 反向代理(公网部署必读) - -面板自身只提供 HTTP,管理员口令、JWT 与租户 API 私钥都会经明文承载。除本机试用外,应让面板仅监听回环地址(compose 示例已默认 `127.0.0.1:18888`),由支持 TLS 的反向代理对外提供 HTTPS。 - -Caddy 最小示例(整站反代,自动申请并续期 Let's Encrypt 证书,WebSocket 自动透传): +### Caddy ```caddyfile portal.example.com { - reverse_proxy 127.0.0.1:18888 + reverse_proxy 127.0.0.1:18888 } ``` -nginx 示例(证书自备;Web Console 的 WebSocket 升级与长超时必须显式配置,否则串行终端连不上或空闲即断): +Caddy 会自动处理 WebSocket。使用 nginx、Traefik 或其他反向代理时,还需要满足: + +- 为串行终端和 VNC 转发 WebSocket Upgrade 头 +- 为 AI 流式响应和控制台连接设置较长的读写超时,建议 `1h` +- 整站请求体上限至少为 `10MB`;后端会继续限制面板 API 为 `1MB`、AI 网关为 `10MB` +- 将面板内「设置 → 安全 → 真实 IP 请求头」配置为反向代理实际写入的头;系统审计、登录锁定和 IP 限速依赖它 +- 配置 `PUBLIC_URL` 或面板地址,供 OAuth 回调和 OCI 日志回传链路使用 + +
+nginx 最小示例 ```nginx -# http 块内:按请求是否升级为 WebSocket 决定 Connection 头 map $http_upgrade $connection_upgrade { default upgrade; "" close; @@ -104,7 +176,6 @@ server { ssl_certificate /etc/nginx/certs/portal.example.com.crt; ssl_certificate_key /etc/nginx/certs/portal.example.com.key; - # 放行到最大入口(AI 网关 10MB);各入口更细的上限由后端自身执行 client_max_body_size 10m; location / { @@ -114,150 +185,263 @@ server { proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # WebSocket 空闲与 AI 流式响应都需要长读超时(默认 60s 会断) proxy_read_timeout 1h; proxy_send_timeout 1h; } } ``` -Traefik 示例(已运行 Traefik 的 Docker 环境,面板容器加 label 接入即可,WebSocket 透明支持): +
-```yaml -services: - oci-portal: - image: ghcr.io/wangdefaa/oci-portal:latest - # ……环境变量与数据卷同上文 compose 示例;与 Traefik 同一 docker 网络, - # Traefik 经容器网络直达 8080,无需(也不应)对外映射 ports - labels: - - traefik.enable=true - - traefik.http.routers.oci-portal.rule=Host(`portal.example.com`) - - traefik.http.routers.oci-portal.entrypoints=websecure - - traefik.http.routers.oci-portal.tls.certresolver=le # 换成你的 certResolver 名 - - traefik.http.services.oci-portal.loadbalancer.server.port=8080 -``` +若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。 -若前端静态文件由反代直接伺服(不走内嵌页面),则按前缀代理,缺一不可: +## AI 网关 -| 前缀 | 内容 | 何时需要 | +AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。 + +| 端点 | 定位 | 流式 | | --- | --- | --- | -| `/api/*` | 面板 REST、Web Console WebSocket、日志回传 webhook | 始终 | -| `/ai/*` | AI 网关(OpenAI / Claude 兼容端点,独立密钥鉴权) | 启用 AI 网关时 | -| 其余路径 | 前端 SPA 静态文件(404 回退 `index.html`) | 静态分离形态 | +| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE;服务端工具除外 | +| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE | +| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE | +| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 | +| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 | -- 反代默认追加的 `X-Forwarded-For` 用于还原真实客户端 IP,系统日志留痕、登录锁定与 IP 限速都依赖它 -- 请求体上限建议与后端一致:`/api/*` 1MB、`/ai/*` 10MB(Caddy 用 `request_body` 按前缀分层) +兼容边界: -## AI 网关接口 +- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响 +- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation` 和 `background:true` +- Responses 支持 xAI Grok `web_search` / `x_search` 服务端工具,但仅限非流式请求 +- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定 +- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供 +- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试 -对外提供 OpenAI / Anthropic 兼容端点,转发 OCI GenAI On-Demand 推理。鉴权用面板创建的 AI 密钥,`Authorization: Bearer sk-...` 与 `x-api-key: sk-...` 双头均可;密钥可绑定渠道分组实现路由隔离,也可配置模型白名单(白名单外调用 404,模型列表只返回交集)。请求体上限 10MB。 +这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](docs/swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。 -| 端点 | 协议 | 流式 | +### 字段兼容矩阵 + +以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对。 + +这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。 + +| 标记 | 含义 | +| :---: | --- | +| ✅ | 网关直接支持 | +| ➡️ | 网关原样透传;是否生效由 OCI 上游决定 | +| 🔄 | 网关进行字段或协议转换后支持 | +| ◐ | 部分支持、存在前置条件或语义降级 | +| ⚠️ | 请求可被接受,但字段会被忽略 | +| ❌ | 网关在请求到达上游前拒绝 | + +
+POST /ai/v1/responses 对比 OpenAI Responses + +Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。 + +| 标准字段 | 状态 | 网关行为 | +| --- | :---: | --- | +| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 | +| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 | +| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 | +| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 | +| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 | +| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 | +| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 | +| `context_management`、`include`、`max_tool_calls`、`metadata`、`moderation`、`prompt` | ➡️ | 网关未建模但会保留在原始请求中,由 OCI 决定是否接受 | +| `prompt_cache_key`、`prompt_cache_retention`、`safety_identifier`、`service_tier` | ➡️ | 原样透传,不代表 OCI 一定实现 OpenAI 的对应语义 | +| `stream_options`、`top_logprobs`、`truncation`、`user` | ➡️ | 原样透传,不做本地语义校验 | +| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` | +| `previous_response_id` | ❌ | 非空即返回 400;网关不保存历史响应 | +| `conversation` | ◐ | 省略或 `null` 可通过;任何非 `null` 值返回 400 | +| `background` | ◐ | `true` 返回 400;`false`、`null` 或省略时继续透传 | +| `stream` | ◐ | 普通请求和 `function` 工具支持 SSE;含 `web_search` / `x_search` 时拒绝流式 | +| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 | +| `tools[].type=web_search` / `x_search` | ◐ | 使用 xAI 服务端工具语义且仅支持非流式;`x_search` 不是 OpenAI 标准工具 | +| 其他标准工具类型 | ❌ | `web_search_preview`、`file_search`、`computer`、`code_interpreter`、`image_generation`、`mcp`、`shell`、`custom` 等返回 400 | +| 其他未知顶层字段 | ➡️ | 只要整个请求是合法 JSON,字段名、结构和数值都会保留 | + +响应边界: + +- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志 +- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤 +- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件 +- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同 + +实现依据:[`airesponses.go`](internal/service/airesponses.go) · [`responses.go`](internal/aiwire/responses.go) · [`aigateway.go`](internal/api/aigateway.go) + +
+ +
+POST /ai/v1/chat/completions 对比 OpenAI Chat Completions + +Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。 + +| 标准字段 | 状态 | 网关行为 | +| --- | :---: | --- | +| `model` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 | +| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` | +| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 | +| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` | +| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 | +| `input_audio`、`file`、`refusal` 等内容块 | ❌ | 当前消息转换器不支持,返回 400 | +| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 | +| 消息 `name`、`refusal`、`audio`、旧式 `function_call` | ⚠️ | 当前消息结构未建模,静默忽略 | +| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` | +| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` | +| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 | +| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]` | +| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 | +| `stream_options.include_obfuscation` | ⚠️ | 未建模,静默忽略 | +| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 | +| `tools[].type=custom` 及其他工具 | ❌ | 只接受 `function`,其他类型返回 400 | +| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 | +| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 | +| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 | +| `store` | ⚠️ / 🔄 | 客户端字段被忽略,转换后的上游请求始终使用 `store:false` | +| `stop`、`seed`、`n`、`frequency_penalty`、`presence_penalty` | ⚠️ | 静默忽略;`n` 因此恒为单个 choice | +| `logprobs`、`top_logprobs`、`logit_bias`、`user` | ⚠️ | 静默忽略 | +| `audio`、`modalities`、`prediction`、`metadata`、`moderation` | ⚠️ | 静默忽略 | +| `prompt_cache_key`、`safety_identifier`、`service_tier`、`verbosity`、`web_search_options` | ⚠️ | 静默忽略 | +| 其他未知字段 | ⚠️ | JSON 解码器不会拒绝未知字段,但转换后的上游请求不包含它们 | + +响应边界: + +- 非流式固定生成一个 `choices[0]`;文本会合并,函数调用转为 `tool_calls` +- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留 +- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回 +- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens` +- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件 + +实现依据:[`chatresponses.go`](internal/service/chatresponses.go) · [`openai.go`](internal/aiwire/openai.go) · [`aigateway.go`](internal/api/aigateway.go) + +
+ +
+POST /ai/v1/embeddings 对比 OpenAI Embeddings + +| 标准字段 | 状态 | 网关行为 | +| --- | :---: | --- | +| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 | +| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI | +| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI | +| `input` 为 token ID 数组或二维 token ID 数组 | ❌ | 只能解码字符串或字符串数组,绑定阶段返回 400 | +| 空数组 / `null` | ❌ | 本地返回 400;空字符串不在本地拒绝,由 OCI 决定 | +| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 | +| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 | +| `encoding_format=base64` | ❌ | 本地返回 400,不提供 base64 响应 | +| `user` | ⚠️ | 能解析但不会传给 OCI | +| 其他未知字段 | ⚠️ | 静默忽略 | + +响应使用标准的 `object:"list"`、`data[].object:"embedding"`、`index`、`model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。 + +实现依据:[`embeddings.go`](internal/aiwire/embeddings.go) · [`aigateway_chat.go`](internal/service/aigateway_chat.go) · [`aigateway.go`](internal/api/aigateway.go) + +
+ +
+POST /ai/v1/messages 对比 Anthropic Messages + +网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。 + +| 标准字段 | 状态 | 网关行为 | +| --- | :---: | --- | +| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 | +| `max_tokens` | 🔄 | 必填且必须大于 0,转换为 `max_output_tokens` | +| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 | +| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 | +| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 | +| `top_k` | ⚠️ | 能解析但不会传给上游 | +| `stop_sequences` | ⚠️ | 能解析但不会传给上游;响应 `stop_sequence` 恒为 `null` | +| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列 | +| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 | +| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 | +| `metadata` | ⚠️ | 能解析但不会传给上游 | +| `thinking` | ⚠️ | 顶层 thinking 配置不会控制上游思考预算 | +| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` | +| `output_config` 其他子字段 | ⚠️ | 未建模,静默忽略 | +| `cache_control`、`container`、`inference_geo`、`service_tier` | ⚠️ | 标准 SDK 中存在,但当前请求结构未建模,静默忽略 | +| 其他未知顶层字段 | ⚠️ | JSON 解码器接受,但转换后的上游请求不包含它们 | + +`messages[].content`: + +| 标准内容块 | 状态 | 网关行为 | +| --- | :---: | --- | +| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` | +| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 | +| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 | +| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 | +| `thinking` / `redacted_thinking` | ⚠️ | 历史思考块被删除,不进入上游上下文 | +| `document`、服务端工具结果及其他未知块 | ❌ | 返回 400 | +| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 | + +响应边界: + +- 非流式只把 Responses `output_text` 转成 `text`、`function_call` 转成 `tool_use` +- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null` +- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature +- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens` +- 流式输出标准事件骨架,但不生成 `thinking_delta`、`signature_delta` 或上游失败对应的 Anthropic `error` 事件 + +实现依据:[`anthresponses.go`](internal/service/anthresponses.go) · [`anthropic.go`](internal/aiwire/anthropic.go) · [`aigateway.go`](internal/api/aigateway.go) + +
+ +`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。 + +## API 与配置 + +### API 文档 + +| 路径 | 鉴权 | 用途 | | --- | --- | --- | -| `POST /ai/v1/responses` | OpenAI Responses(无状态子集) | SSE 支持(服务端工具除外) | -| `POST /ai/v1/messages` | Anthropic Messages | SSE 支持 | -| `POST /ai/v1/embeddings` | OpenAI Embeddings | — | -| `GET /ai/v1/models` | OpenAI 模型列表(来自渠道模型目录) | — | +| `/api/v1/*` | 登录返回的 JWT Bearer Token | 面板管理 API | +| `/ai/v1/*` | AI 网关密钥 | OpenAI / Anthropic 兼容接口 | +| `/api/v1/webhooks/oci-logs/:secret` | URL 中的独立回传密钥 | OCI Notifications 日志回传 | -对话端点统一转发 OCI OpenAI 兼容面(`/actions/v1/responses`),因此仅提供该面支持的 `xai.` / `meta.` / `openai.` 前缀模型;google / cohere 对话模型不在兼容面供给(上游 400),不再提供(cohere embed 模型不受影响)。兼容面属实测可用但无 Oracle 文档承诺的能力,行为可能随上游调整。**Chat Completions(`/ai/v1/chat/completions`)端点已移除**,请迁移到 Responses 或 Messages。 +OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`docs/swagger.json`](docs/swagger.json)。 -以下各表对照网关行为:✅ 转发上游;⚠️ 接受但忽略(静默丢弃,不影响请求);❌ 拒绝(400,不发上游)。 +运行进程时设置 `SWAGGER=1` 可开放 `/swagger/index.html`。Swagger 默认关闭,生产环境建议仅在受控网络内按需开启。接口注释变更后必须重新生成 OpenAPI。 -### Responses 字段 - -请求体除下列例外**原样直通上游**(未列字段一并转发,效果由上游决定): - -| 字段 | 支持 | 说明 | -| --- | --- | --- | -| `model`、`input` | ✅ | `input` 接受 string 或 item 数组 | -| `stream` | ✅ | SSE,上游原生事件流(含 gpt-oss 推理增量);与服务端工具互斥 | -| `reasoning.effort` | ✅ | 直达上游不限档位,见下「推理力度」 | -| `store` | ⚠️ | 网关无状态,强制改写为 `false` | -| `previous_response_id`、`conversation`、`background` | ❌ | 网关不保存历史,请求需自带完整上下文 | -| `web_search` / `x_search` / `function` 之外的工具类型 | ❌ | 如 `file_search`、`code_interpreter`、`mcp` | - -**服务端工具**:`tools` 支持 `{"type":"web_search"}` 与 `{"type":"x_search"}`(xAI Grok 官方 Agent Tools 格式,可与 `function` 混用),响应保留 `web_search_call` 输出项与引用;**仅非流式**。响应 `usage.input_tokens_details.cached_tokens` 透传缓存命中量。 - -### Anthropic Messages 字段 - -网关把 Messages 请求转换为 Responses 请求送上游,响应(含流式事件序列)转回 Anthropic 形态: - -| 字段 | 支持 | 说明 | -| --- | --- | --- | -| `model`、`max_tokens`、`messages` | ✅ | `max_tokens` 必填;content 块支持 `text` / `image` / `tool_use` / `tool_result`,其余块类型 400 | -| `system` | ✅ | string 或块数组,映射为 `instructions` | -| `temperature`、`top_p` | ✅ | | -| `stream` | ✅ | SSE,标准 Anthropic 事件序列(`message_start` → `content_block_*` → `message_delta` → `message_stop`) | -| `tools`、`tool_choice` | ✅ | 映射为 Responses `function` 工具;`tool_choice` 支持 `auto` / `any` / `none` / `tool` | -| `output_config.effort` | ✅ | 直达上游不限档位,见下「推理力度」 | -| `stop_sequences`、`top_k`、`metadata`、`thinking`、`service_tier` | ⚠️ | Responses 面无对应物,静默忽略 | - -模型的思考输出(reasoning)不转为 `thinking` 块,静默丢弃;`usage.cache_read_input_tokens` 透传缓存命中量。 - -### Embeddings 字段 - -| 字段 | 支持 | 说明 | -| --- | --- | --- | -| `model`、`input` | ✅ | `input` 接受 string 或数组 | -| `dimensions` | ✅ | | -| `encoding_format`、`user` | ⚠️ | 输出恒为 float 数组 | - -### 推理力度(effort) - -两个对话协议各以原生字段控制推理深度,取值直达上游不做档位校验: - -| 协议 | 字段 | -| --- | --- | -| Responses | `reasoning.effort` | -| Anthropic Messages | `output_config.effort`(转小写后透传) | - -是否支持、可用档位与实际效果由模型决定(2026-07 兼容面实测): - -| 模型 | 支持档位 | 实测行为 | -| --- | --- | --- | -| `xai.grok-4.3` | `none` / `minimal` / `low` / `medium` / `high` | 全部生效:`none` 完全关闭推理,默认 `low`,`high` 推理量最大 | -| `openai.gpt-oss-*` | `minimal` / `low` / `medium` / `high` | `none` 被上游 400(Harmony 格式不支持);思考计入 completion tokens | -| `xai.grok-3-mini(-fast)` | 名义 `low` / `high` | 各档均被接受,但 `none` 并不关闭推理(仍产生思考) | -| `meta.llama-*` | 接受任意档 | 非推理模型,参数无实际效果 | -| `xai.grok-4.20-multi-agent*` | `low` / `medium` / `high` / `xhigh` | effort 控制并行 agent 数(4 / 16)而非推理深度 | -| 其余 grok(`grok-3`、`grok-4`、`grok-4-fast-*`、`grok-4-1-fast-*`、`grok-code-fast-1`、`grok-4.20-*` 单/双型号全系) | 不支持 | 携带即被上游 400(`does not support parameter reasoningEffort`) | - -不携带该字段时网关不下发,行为由模型默认档位决定。 - -### 通用行为 - -- 未知模型 404;无可用渠道 503;单次请求最多尝试 3 个渠道(429/5xx/网络错误自动换渠道并计入熔断,模型级 400/404 换渠道不计熔断,其余 4xx 原样透传);流式建立后绑定渠道,中断不重试 -- 调用计量(渠道、token 用量、缓存命中、时延、重试数)写入面板「调用日志」;密钥可按需开启限时内容抓取 - -## 环境变量 +### 环境变量 | 变量 | 必填 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `DATA_KEY` | 是 | 无 | 敏感字段加密主密钥(更换后已入库密文无法解密) | -| `JWT_SECRET` | 是 | 无 | 登录令牌签名密钥 | +| --- | :---: | --- | --- | +| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 | +| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 | | `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 | -| `ADMIN_PASSWORD` | 首次启动是 | 无 | 仅在用户不存在时创建;已存在不重置 | +| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 | | `ADDR` | 否 | `:8080` | HTTP 监听地址 | -| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`(后两者 experimental) | -| `DB_DSN` | 外部库时是 | 无 | MySQL 需 `parseTime=True`;PostgreSQL 标准 DSN | -| `DB_PATH` | 否 | `oci-portal.db` | SQLite 文件路径 | -| `PUBLIC_URL` | 否 | 无 | 面板公网基址,日志回传一键创建链路用 | -| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) | -| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) | -| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) | -| `GIN_MODE` | 否 | `release` | `debug` / `release`,影响日志与调试输出 | +| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental | +| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 | +| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 | +| `PUBLIC_URL` | 否 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 | +| `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` | +| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 | +| `SWAGGER` | 否 | 关闭 | 设为 `1` 时开放 Swagger UI | +| `GIN_MODE` | 否 | `release` | `debug` / `release` | + +## 升级与备份 + +- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db` +- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密 +- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md) +- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像 +- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源 ## 开发 ```bash -go test ./... # 全量测试 -go vet ./... && gofmt -l . -go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency # 接口注释变更后重新生成 OpenAPI +gofmt -l . +go vet ./... +go test ./... + +# Handler 注释变更后重新生成唯一的对外 API 文档。 +go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency ``` -API 文档:全部接口带 swaggo 注释,`SWAGGER=1` 启动后访问 `/swagger/index.html`;spec 文件在 `docs/swagger.json|yaml`。 - -编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。 +- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/) +- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash) +- 版本变更:[`CHANGELOG.md`](CHANGELOG.md) ## License diff --git a/cmd/server/main.go b/cmd/server/main.go index a27b92f..cb8aa22 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -135,7 +135,7 @@ func run() error { defer aiGateway.Wait() defer stopCleanup() tasks := service.NewTaskService(db, ociConfigs, notifier, settings) - ociConfigs.SetTenantCleanupDeps(tasks, logEvents) + ociConfigs.SetTenantCleanupDeps(tasks) tasks.AttachAiGateway(aiGateway) aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask) if err := tasks.Start(); err != nil { diff --git a/docs/docs.go b/docs/docs.go index 52e4b7a..cca3630 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,34 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/ai/v1/chat/completions": { + "post": { + "tags": [ + "AI 网关" + ], + "summary": "OpenAI Chat Completions 兼容端点", + "parameters": [ + { + "description": "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/ai/v1/embeddings": { "post": { "tags": [ @@ -1154,124 +1182,6 @@ const docTemplate = `{ } } }, - "/api/v1/log-events/alert-rules": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "告警规则列表", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "创建告警规则", - "parameters": [ - { - "description": "请求体", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_api.alertRuleRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/v1/log-events/alert-rules/{ruleId}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "更新告警规则", - "parameters": [ - { - "type": "integer", - "description": "规则 ID", - "name": "ruleId", - "in": "path", - "required": true - }, - { - "description": "请求体", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_api.alertRuleRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "删除告警规则", - "parameters": [ - { - "type": "integer", - "description": "规则 ID", - "name": "ruleId", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "无内容" - } - } - } - }, "/api/v1/oci-configs": { "get": { "security": [ @@ -5863,38 +5773,6 @@ const docTemplate = `{ } }, "definitions": { - "internal_api.alertRuleRequest": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "eventTypes": { - "type": "string" - }, - "name": { - "type": "string" - }, - "ociConfigId": { - "type": "integer" - }, - "resourceMatch": { - "type": "string" - }, - "sourceIpMode": { - "type": "string" - }, - "sourceIps": { - "type": "string" - }, - "threshold": { - "type": "integer" - }, - "windowMinutes": { - "type": "integer" - } - } - }, "internal_api.attachBootVolumeRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 58419e4..7f7361a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8,6 +8,34 @@ }, "basePath": "/", "paths": { + "/ai/v1/chat/completions": { + "post": { + "tags": [ + "AI 网关" + ], + "summary": "OpenAI Chat Completions 兼容端点", + "parameters": [ + { + "description": "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/ai/v1/embeddings": { "post": { "tags": [ @@ -1147,124 +1175,6 @@ } } }, - "/api/v1/log-events/alert-rules": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "告警规则列表", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "创建告警规则", - "parameters": [ - { - "description": "请求体", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_api.alertRuleRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/v1/log-events/alert-rules/{ruleId}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "更新告警规则", - "parameters": [ - { - "type": "integer", - "description": "规则 ID", - "name": "ruleId", - "in": "path", - "required": true - }, - { - "description": "请求体", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_api.alertRuleRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "tags": [ - "任务与日志回传" - ], - "summary": "删除告警规则", - "parameters": [ - { - "type": "integer", - "description": "规则 ID", - "name": "ruleId", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "无内容" - } - } - } - }, "/api/v1/oci-configs": { "get": { "security": [ @@ -5856,38 +5766,6 @@ } }, "definitions": { - "internal_api.alertRuleRequest": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "eventTypes": { - "type": "string" - }, - "name": { - "type": "string" - }, - "ociConfigId": { - "type": "integer" - }, - "resourceMatch": { - "type": "string" - }, - "sourceIpMode": { - "type": "string" - }, - "sourceIps": { - "type": "string" - }, - "threshold": { - "type": "integer" - }, - "windowMinutes": { - "type": "integer" - } - } - }, "internal_api.attachBootVolumeRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8c06b62..c08f00c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,26 +1,5 @@ basePath: / definitions: - internal_api.alertRuleRequest: - properties: - enabled: - type: boolean - eventTypes: - type: string - name: - type: string - ociConfigId: - type: integer - resourceMatch: - type: string - sourceIpMode: - type: string - sourceIps: - type: string - threshold: - type: integer - windowMinutes: - type: integer - type: object internal_api.attachBootVolumeRequest: properties: bootVolumeId: @@ -599,6 +578,24 @@ info: title: OCI Portal API version: 0.0.1 paths: + /ai/v1/chat/completions: + post: + parameters: + - description: OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通) + in: body + name: body + required: true + schema: + type: object + responses: + "200": + description: 'OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])' + schema: + additionalProperties: true + type: object + summary: OpenAI Chat Completions 兼容端点 + tags: + - AI 网关 /ai/v1/embeddings: post: parameters: @@ -1297,78 +1294,6 @@ paths: summary: 回传日志事件列表 tags: - 任务与日志回传 - /api/v1/log-events/alert-rules: - get: - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - BearerAuth: [] - summary: 告警规则列表 - tags: - - 任务与日志回传 - post: - parameters: - - description: 请求体 - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_api.alertRuleRequest' - responses: - "201": - description: Created - schema: - additionalProperties: true - type: object - security: - - BearerAuth: [] - summary: 创建告警规则 - tags: - - 任务与日志回传 - /api/v1/log-events/alert-rules/{ruleId}: - delete: - parameters: - - description: 规则 ID - in: path - name: ruleId - required: true - type: integer - responses: - "204": - description: 无内容 - security: - - BearerAuth: [] - summary: 删除告警规则 - tags: - - 任务与日志回传 - put: - parameters: - - description: 规则 ID - in: path - name: ruleId - required: true - type: integer - - description: 请求体 - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_api.alertRuleRequest' - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - BearerAuth: [] - summary: 更新告警规则 - tags: - - 任务与日志回传 /api/v1/oci-configs: get: responses: diff --git a/internal/aiwire/openai.go b/internal/aiwire/openai.go index c3248a4..9204381 100644 --- a/internal/aiwire/openai.go +++ b/internal/aiwire/openai.go @@ -1,9 +1,11 @@ -// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Anthropic Messages / -// Embeddings 与通用记账、错误、模型列表结构。上游统一为 OCI OpenAI 兼容面。 +// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Chat Completions / +// Anthropic Messages / Embeddings 与通用记账、错误、模型列表结构。上游统一为 +// OCI OpenAI 兼容面。 package aiwire import ( "encoding/json" + "strings" ) // StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。 @@ -73,3 +75,181 @@ type ErrorDetail struct { Type string `json:"type"` Code string `json:"code,omitempty"` } + +// ---- Chat Completions 线格式(/ai/v1/chat/completions,Tier 2 兼容层)---- + +// ChatRequest 是 OpenAI /v1/chat/completions 请求体;只声明网关读取的字段, +// 无对应物的字段(stop / seed / n / penalty 等)由 JSON 解码天然忽略。 +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + MaxTokens *int `json:"max_tokens,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` +} + +// StreamOptions 控制流式末尾是否附带 usage 块。 +type StreamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +// ChatMessage 是请求与非流式响应共用的消息结构。 +type ChatMessage struct { + Role string `json:"role"` + Content Content `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// Content 兼容 string 与多模态块数组两种线格式,序列化时保形。 +type Content struct { + Text string + Parts []ContentPart + IsArray bool +} + +func (c *Content) UnmarshalJSON(b []byte) error { + t := strings.TrimSpace(string(b)) + if t == "null" { + return nil + } + if strings.HasPrefix(t, "[") { + c.IsArray = true + return json.Unmarshal(b, &c.Parts) + } + return json.Unmarshal(b, &c.Text) +} + +func (c Content) MarshalJSON() ([]byte, error) { + if c.IsArray { + return json.Marshal(c.Parts) + } + return json.Marshal(c.Text) +} + +// NewTextContent 构造字符串形态的内容。 +func NewTextContent(text string) Content { + return Content{Text: text} +} + +// JoinText 拍平内容为纯文本(数组形态拼接 text 块)。 +func (c Content) JoinText() string { + if !c.IsArray { + return c.Text + } + var sb strings.Builder + for _, p := range c.Parts { + if p.Type == "" || p.Type == "text" { + sb.WriteString(p.Text) + } + } + return sb.String() +} + +// ContentPart 是块数组中的一段;text 与 image_url 被网关承接,其余类型拒绝。 +type ContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址。 +type ImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +// Tool 是嵌套形态的 function 工具定义。 +type Tool struct { + Type string `json:"type"` + Function FunctionDef `json:"function"` +} + +// FunctionDef 描述工具的名称与 JSON Schema 参数。 +type FunctionDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +// ToolCall 是助手消息中的工具调用。 +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +// FunctionCall 携带调用名与 JSON 编码的实参。 +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// ResponseFormat 对应 response_format(json_object / json_schema)。 +type ResponseFormat struct { + Type string `json:"type"` + JSONSchema json.RawMessage `json:"json_schema,omitempty"` +} + +// ChatResponse 是非流式响应体(chat.completion)。 +type ChatResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +// Choice 是一条候选回复。 +type Choice struct { + Index int `json:"index"` + Message ChatMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// ChatChunk 是流式增量事件体(chat.completion.chunk)。 +type ChatChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []ChunkChoice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +// ChunkChoice 是流式事件中的增量候选。 +type ChunkChoice struct { + Index int `json:"index"` + Delta Delta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +// Delta 是流式增量内容;Content 为纯文本片段。 +type Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` +} + +// ToolCallDelta 是工具调用的流式片段;Index 标识聚合目标。 +type ToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function FunctionCallDelta `json:"function"` +} + +// FunctionCallDelta 是函数名 / 实参的增量片段。 +type FunctionCallDelta struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} diff --git a/internal/api/aigateway.go b/internal/api/aigateway.go index b6a3f07..0f2766f 100644 --- a/internal/api/aigateway.go +++ b/internal/api/aigateway.go @@ -281,7 +281,7 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi defer upstream.Close() sseHeaders(c) bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model) - if err := forwardAnthSSE(c, upstream, bridge); err != nil { + if err := forwardSSEData(upstream, func(data []byte) { writeAnthEvents(c, bridge.Feed(data)) }); err != nil { entry.ErrMsg = err.Error() } writeAnthEvents(c, bridge.Finish()) @@ -295,15 +295,16 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil) } -// forwardAnthSSE 逐行读上游 SSE,把 data 行喂给桥并即时写出转换后的事件。 -func forwardAnthSSE(c *gin.Context, upstream io.Reader, bridge *service.AnthRespBridge) error { +// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出; +// Anthropic 与 Chat Completions 两条流式桥共用。 +func forwardSSEData(upstream io.Reader, emit func([]byte)) error { reader := bufio.NewReader(upstream) for { line, err := reader.ReadBytes('\n') if len(line) > 0 { trimmed := bytes.TrimSpace(line) if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok { - writeAnthEvents(c, bridge.Feed(data)) + emit(data) } } if err != nil { @@ -328,6 +329,109 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) { c.Writer.Flush() } +// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层, +// 承接只会说 Chat Completions 的存量客户端)。 +// +// @Summary OpenAI Chat Completions 兼容端点 +// @Tags AI 网关 +// @Param body body object true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)" +// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])" +// @Router /ai/v1/chat/completions [post] +func (h *aiGatewayHandler) chatCompletions(c *gin.Context) { + var req aiwire.ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 { + aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空") + return + } + if !checkKeyModel(c, req.Model) { + return + } + body, err := service.ChatToResponsesBody(req) + if err != nil { + aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + if req.Stream { + h.streamChat(c, body, req) + return + } + h.chatOnce(c, body, req) +} + +// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。 +func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) { + start := time.Now() + payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c)) + entry := h.logEntry(c, "openai", req.Model, false, meta, start) + if err != nil { + upstreamError(c, err) + entry.ErrMsg = err.Error() + h.logFailure(c, entry, req) + return + } + out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix()) + if err != nil { + aiError(c, http.StatusBadGateway, "api_error", err.Error()) + entry.ErrMsg = err.Error() + h.logFailure(c, entry, req) + return + } + entry.Status = http.StatusOK + fillUsage(&entry, out.Usage) + callID := h.gw.LogCall(entry) + h.maybeLogContent(c, callID, "openai", req.Model, false, req, out) + c.JSON(http.StatusOK, out) +} + +// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为 +// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。 +func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) { + start := time.Now() + upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c)) + entry := h.logEntry(c, "openai", req.Model, true, meta, start) + if err != nil { + upstreamError(c, err) + entry.ErrMsg = err.Error() + h.logFailure(c, entry, req) + return + } + defer upstream.Close() + sseHeaders(c) + includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage + bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage) + if err := forwardSSEData(upstream, func(data []byte) { writeChatChunks(c, bridge.Feed(data)) }); err != nil { + entry.ErrMsg = err.Error() + } + writeChatChunks(c, bridge.Finish()) + c.Writer.WriteString("data: [DONE]\n\n") + c.Writer.Flush() + entry.Status = http.StatusOK + entry.LatencyMs = time.Since(start).Milliseconds() + fillUsage(&entry, bridge.Usage()) + callID := h.gw.LogCall(entry) + h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil) +} + +// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。 +func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) { + for _, ch := range chunks { + b, err := json.Marshal(ch) + if err != nil { + continue + } + c.Writer.WriteString("data: ") + c.Writer.Write(b) + c.Writer.WriteString("\n\n") + } + if len(chunks) > 0 { + c.Writer.Flush() + } +} + // listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。 // // @Summary 可用模型列表 diff --git a/internal/api/aigateway_test.go b/internal/api/aigateway_test.go index aa0dec8..0d22f65 100644 --- a/internal/api/aigateway_test.go +++ b/internal/api/aigateway_test.go @@ -81,8 +81,18 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) { `{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny}, {"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses", `{"model":"ghost-model","input":"hi"}`, 404, pass}, - {"chat completions 端点已删除", "open-key-12345", "/ai/v1/chat/completions", - `{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, []string{"not found"}}, + {"chat completions 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions", + `{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny}, + {"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions", + `{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass}, + {"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions", + `{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}}, + {"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions", + `{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`, + 400, []string{"仅支持 function"}}, + {"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions", + `{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`, + 400, []string{"invalid_request_error"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/logevent.go b/internal/api/logevent.go index 11d33dc..98ebf8d 100644 --- a/internal/api/logevent.go +++ b/internal/api/logevent.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" - "oci-portal/internal/model" "oci-portal/internal/service" ) @@ -107,127 +106,6 @@ func (h *logEventHandler) list(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) } -// alertRuleRequest 是创建/更新告警规则的请求体。 -type alertRuleRequest struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - OciConfigID uint `json:"ociConfigId"` - EventTypes string `json:"eventTypes"` - SourceIPs string `json:"sourceIps"` - SourceIPMode string `json:"sourceIpMode"` - ResourceMatch string `json:"resourceMatch"` - Threshold int `json:"threshold"` - WindowMinutes int `json:"windowMinutes"` -} - -// toModel 转为规则模型(默认阈值 1)。 -func (r alertRuleRequest) toModel() model.AlertRule { - if r.Threshold == 0 { - r.Threshold = 1 - } - return model.AlertRule{ - Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID, - EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode, - ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes, - } -} - -// listAlertRules 返回全部告警规则。 -// -// @Summary 告警规则列表 -// @Tags 任务与日志回传 -// @Success 200 {object} map[string]any -// @Security BearerAuth -// @Router /api/v1/log-events/alert-rules [get] -func (h *logEventHandler) listAlertRules(c *gin.Context) { - rules, err := h.svc.ListAlertRules(c.Request.Context()) - if err != nil { - respondError(c, err) - return - } - c.JSON(http.StatusOK, gin.H{"items": rules}) -} - -// createAlertRule 创建告警规则;字段非法返回 400。 -// -// @Summary 创建告警规则 -// @Tags 任务与日志回传 -// @Param body body alertRuleRequest true "请求体" -// @Success 201 {object} map[string]any -// @Security BearerAuth -// @Router /api/v1/log-events/alert-rules [post] -func (h *logEventHandler) createAlertRule(c *gin.Context) { - var req alertRuleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel()) - if err != nil { - respondAlertRuleError(c, err) - return - } - c.JSON(http.StatusCreated, rule) -} - -// updateAlertRule 整体覆盖告警规则(含启停)。 -// -// @Summary 更新告警规则 -// @Tags 任务与日志回传 -// @Param ruleId path int true "规则 ID" -// @Param body body alertRuleRequest true "请求体" -// @Success 200 {object} map[string]any -// @Security BearerAuth -// @Router /api/v1/log-events/alert-rules/{ruleId} [put] -func (h *logEventHandler) updateAlertRule(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"}) - return - } - var req alertRuleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel()) - if err != nil { - respondAlertRuleError(c, err) - return - } - c.JSON(http.StatusOK, rule) -} - -// deleteAlertRule 删除告警规则及其命中记录。 -// -// @Summary 删除告警规则 -// @Tags 任务与日志回传 -// @Param ruleId path int true "规则 ID" -// @Success 204 "无内容" -// @Security BearerAuth -// @Router /api/v1/log-events/alert-rules/{ruleId} [delete] -func (h *logEventHandler) deleteAlertRule(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"}) - return - } - if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil { - respondError(c, err) - return - } - c.Status(http.StatusNoContent) -} - -// respondAlertRuleError 把字段校验错误映射为 400。 -func respondAlertRuleError(c *gin.Context, err error) { - if errors.Is(err, service.ErrInvalidAlertRule) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - respondError(c, err) -} - // getRelay 查询 OCI 侧链路状态与关键事件清单。 // // @Summary 查询 OCI 侧链路状态与关键事件清单 diff --git a/internal/api/routes_ai.go b/internal/api/routes_ai.go index 2643858..9538497 100644 --- a/internal/api/routes_ai.go +++ b/internal/api/routes_ai.go @@ -11,6 +11,7 @@ import ( func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) { aih := &aiGatewayHandler{gw: aiGateway} ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth) + ai.POST("/chat/completions", aih.chatCompletions) ai.POST("/responses", aih.responses) ai.POST("/messages", aih.messages) ai.POST("/embeddings", aih.embeddings) diff --git a/internal/api/routes_tasks.go b/internal/api/routes_tasks.go index 6d0c30d..40851e2 100644 --- a/internal/api/routes_tasks.go +++ b/internal/api/routes_tasks.go @@ -19,10 +19,6 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService, le := &logEventHandler{svc: logEvents} secured.GET("/log-events", le.list) - secured.GET("/log-events/alert-rules", le.listAlertRules) - secured.POST("/log-events/alert-rules", le.createAlertRule) - secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule) - secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule) secured.GET("/oci-configs/:id/log-webhook", le.getWebhook) secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook) secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook) diff --git a/internal/database/database.go b/internal/database/database.go index 1d81741..fc4898a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -67,7 +67,7 @@ func autoMigrate(db *gorm.DB) error { &model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{}, - &model.SystemLog{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.Proxy{}, + &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}, &model.AiContentLog{}, ) diff --git a/internal/model/models.go b/internal/model/models.go index 6dabf68..e7cd491 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -211,31 +211,6 @@ type LogEvent struct { ReceivedAt time.Time `json:"receivedAt"` } -// AlertRule 是回传事件的自定义告警规则;条件间 AND 关系,空条件视为任意。 -type AlertRule struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:64" json:"name"` - Enabled bool `json:"enabled"` - OciConfigID uint `json:"ociConfigId"` // 0=全部租户 - EventTypes string `gorm:"size:512" json:"eventTypes"` // 逗号分隔事件短名,空=全部 - SourceIPs string `gorm:"size:512" json:"sourceIps"` // 逗号分隔 IP/CIDR,空=任意 - // in:来源命中列表才告警(默认);notin:不在列表才告警(白名单场景) - SourceIPMode string `gorm:"size:8" json:"sourceIpMode"` - ResourceMatch string `gorm:"size:128" json:"resourceMatch"` // 资源名子串,空=任意 - Threshold int `json:"threshold"` // 触发阈值,默认 1(即时) - WindowMinutes int `json:"windowMinutes"` // 聚合窗口,Threshold>1 时必填 - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// AlertRuleHit 记录规则命中,供阈值窗口计数;随周期清理删除过期行。 -type AlertRuleHit struct { - ID uint `gorm:"primaryKey" json:"id"` - RuleID uint `gorm:"index" json:"ruleId"` - LogEventID uint `json:"logEventId"` - HitAt time.Time `gorm:"index" json:"hitAt"` -} - // RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。 // Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。 type RegionCache struct { diff --git a/internal/service/airesponses.go b/internal/service/airesponses.go index cdcbfe3..87cdaf0 100644 --- a/internal/service/airesponses.go +++ b/internal/service/airesponses.go @@ -73,12 +73,20 @@ func RespPassthroughUsage(payload []byte) *aiwire.Usage { var root struct { Usage *aiwire.RespUsage `json:"usage"` } - if json.Unmarshal(payload, &root) != nil || root.Usage == nil { + if json.Unmarshal(payload, &root) != nil { return nil } - usage := &aiwire.Usage{PromptTokens: root.Usage.InputTokens, - CompletionTokens: root.Usage.OutputTokens, TotalTokens: root.Usage.TotalTokens} - if cached := root.Usage.InputTokensDetails.CachedTokens; cached > 0 { + return usageFromResp(root.Usage) +} + +// usageFromResp 换算 Responses usage 为 OpenAI 口径(缓存命中透传);nil 原样返回。 +func usageFromResp(u *aiwire.RespUsage) *aiwire.Usage { + if u == nil { + return nil + } + usage := &aiwire.Usage{PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, TotalTokens: u.TotalTokens} + if cached := u.InputTokensDetails.CachedTokens; cached > 0 { usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached} } return usage diff --git a/internal/service/alertrule.go b/internal/service/alertrule.go deleted file mode 100644 index d3c35e6..0000000 --- a/internal/service/alertrule.go +++ /dev/null @@ -1,305 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "log" - "net/netip" - "slices" - "strings" - "time" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "oci-portal/internal/model" -) - -// 告警规则约束与命中记录保留期(窗口计数之外多留几天便于排查)。 -const ( - alertMaxThreshold = 100 - alertMaxWindowMin = 1440 - alertHitRetention = 7 * 24 * time.Hour - alertSourceIPIn = "in" - alertSourceIPNotIn = "notin" -) - -// ErrInvalidAlertRule 标记规则字段非法,api 层映射 400。 -var ErrInvalidAlertRule = fmt.Errorf("告警规则字段非法") - -// ListAlertRules 返回全部告警规则(创建顺序)。 -func (s *LogEventService) ListAlertRules(ctx context.Context) ([]model.AlertRule, error) { - var rules []model.AlertRule - if err := s.db.WithContext(ctx).Order("id").Find(&rules).Error; err != nil { - return nil, fmt.Errorf("list alert rules: %w", err) - } - return rules, nil -} - -// CreateAlertRule 校验并创建规则。 -func (s *LogEventService) CreateAlertRule(ctx context.Context, rule model.AlertRule) (model.AlertRule, error) { - if err := validateAlertRule(&rule); err != nil { - return model.AlertRule{}, err - } - rule.ID = 0 - if err := s.db.WithContext(ctx).Create(&rule).Error; err != nil { - return model.AlertRule{}, fmt.Errorf("create alert rule: %w", err) - } - return rule, nil -} - -// UpdateAlertRule 校验并整体覆盖规则(含启停)。 -func (s *LogEventService) UpdateAlertRule(ctx context.Context, id uint, rule model.AlertRule) (model.AlertRule, error) { - if err := validateAlertRule(&rule); err != nil { - return model.AlertRule{}, err - } - var cur model.AlertRule - if err := s.db.WithContext(ctx).First(&cur, id).Error; err != nil { - return model.AlertRule{}, fmt.Errorf("find alert rule %d: %w", id, err) - } - rule.ID, rule.CreatedAt = cur.ID, cur.CreatedAt - if err := s.db.WithContext(ctx).Save(&rule).Error; err != nil { - return model.AlertRule{}, fmt.Errorf("update alert rule: %w", err) - } - return rule, nil -} - -// DeleteAlertRule 删除规则及其命中记录。 -func (s *LogEventService) DeleteAlertRule(ctx context.Context, id uint) error { - if err := s.db.WithContext(ctx).Delete(&model.AlertRule{}, id).Error; err != nil { - return fmt.Errorf("delete alert rule: %w", err) - } - if err := s.db.WithContext(ctx).Where("rule_id = ?", id).Delete(&model.AlertRuleHit{}).Error; err != nil { - return fmt.Errorf("delete alert rule hits: %w", err) - } - return nil -} - -// validateAlertRule 校验字段并归一化;非法时返回含具体原因的 ErrInvalidAlertRule 包装。 -func validateAlertRule(rule *model.AlertRule) error { - rule.Name = strings.TrimSpace(rule.Name) - if rule.Name == "" { - return fmt.Errorf("%w: 名称必填", ErrInvalidAlertRule) - } - if rule.SourceIPMode == "" { - rule.SourceIPMode = alertSourceIPIn - } - if rule.SourceIPMode != alertSourceIPIn && rule.SourceIPMode != alertSourceIPNotIn { - return fmt.Errorf("%w: 来源 IP 模式须为 in/notin", ErrInvalidAlertRule) - } - if rule.Threshold < 1 || rule.Threshold > alertMaxThreshold { - return fmt.Errorf("%w: 阈值须在 1-%d 之间", ErrInvalidAlertRule, alertMaxThreshold) - } - if rule.Threshold > 1 && (rule.WindowMinutes < 1 || rule.WindowMinutes > alertMaxWindowMin) { - return fmt.Errorf("%w: 阈值>1 时窗口须在 1-%d 分钟之间", ErrInvalidAlertRule, alertMaxWindowMin) - } - if rule.EventTypes != "" { - rule.EventTypes = normalizeCSV(rule.EventTypes) - } - return validateAlertRuleIPs(rule) -} - -// validateAlertRuleIPs 归一化并校验来源 IP 列表(裸 IP 或 CIDR)。 -func validateAlertRuleIPs(rule *model.AlertRule) error { - if rule.SourceIPs == "" { - return nil - } - rule.SourceIPs = normalizeCSV(rule.SourceIPs) - for _, item := range strings.Split(rule.SourceIPs, ",") { - if _, err := parseIPMatcher(item); err != nil { - return fmt.Errorf("%w: 来源 IP %q 不是合法的 IP 或 CIDR", ErrInvalidAlertRule, item) - } - } - return nil -} - -// normalizeCSV 去除各项空白与空项后重组逗号分隔串。 -func normalizeCSV(s string) string { - parts := strings.Split(s, ",") - out := parts[:0] - for _, p := range parts { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return strings.Join(out, ",") -} - -// parseIPMatcher 把裸 IP 或 CIDR 解析为前缀(裸 IP 视为单地址前缀)。 -func parseIPMatcher(item string) (netip.Prefix, error) { - if strings.Contains(item, "/") { - return netip.ParsePrefix(item) - } - addr, err := netip.ParseAddr(item) - if err != nil { - return netip.Prefix{}, err - } - return netip.PrefixFrom(addr, addr.BitLen()), nil -} - -// ipListMatch 报告 ip 是否命中列表中的任一前缀;ip 解析失败视为未命中。 -func ipListMatch(list, ip string) bool { - addr, err := netip.ParseAddr(ip) - if err != nil { - return false - } - for _, item := range strings.Split(list, ",") { - if p, err := parseIPMatcher(item); err == nil && p.Contains(addr) { - return true - } - } - return false -} - -// ruleHits 报告事件是否命中规则的全部条件(AND 语义,空条件视为任意)。 -func ruleHits(rule model.AlertRule, e *model.LogEvent, p parsedEvent) bool { - if rule.OciConfigID != 0 && rule.OciConfigID != e.OciConfigID { - return false - } - name := relayEventShortName(p.EventType) - if rule.EventTypes != "" && !slices.Contains(strings.Split(rule.EventTypes, ","), name) { - return false - } - if rule.ResourceMatch != "" && !strings.Contains(p.ResourceName, rule.ResourceMatch) { - return false - } - return ruleIPHits(rule, p.SourceIP) -} - -// ruleIPHits 按模式判定来源 IP 条件:in 命中列表告警;notin 不在列表才告警, -// 事件缺 IP 字段时 notin 不告警(避免解析缺字段导致白名单误报)。 -func ruleIPHits(rule model.AlertRule, ip string) bool { - if rule.SourceIPs == "" { - return true - } - if rule.SourceIPMode == alertSourceIPNotIn { - return ip != "" && !ipListMatch(rule.SourceIPs, ip) - } - return ipListMatch(rule.SourceIPs, ip) -} - -// matchAlertRules 对一条已解析事件执行全部启用规则;任何内部错误只记日志,不影响解析主流程。 -func (s *LogEventService) matchAlertRules(ctx context.Context, rules []model.AlertRule, e *model.LogEvent, p parsedEvent) { - if s.notifier == nil { - return - } - for _, rule := range rules { - if !rule.Enabled || !ruleHits(rule, e, p) { - continue - } - count, ok := s.recordAlertHit(ctx, rule, e) - if !ok || count < rule.Threshold || !s.alertCooldownPass(rule) { - continue - } - s.notifier.SendTemplateAsync("audit_alert", map[string]string{ - "rule": rule.Name, "tenant": s.configAlias(ctx, e.OciConfigID), - "event": relayEventShortName(p.EventType), "resource": p.ResourceName, - "ip": p.SourceIP, "count": fmt.Sprint(count), - }) - } -} - -// recordAlertHit 落一条命中并返回窗口内累计次数;阈值 1 的规则免计数直接触发。 -func (s *LogEventService) recordAlertHit(ctx context.Context, rule model.AlertRule, e *model.LogEvent) (int, bool) { - count, err := s.recordAlertHitTx(ctx, rule, e) - if err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - log.Printf("alert rule hit record: %v", err) - } - return 0, false - } - return count, true -} - -func (s *LogEventService) recordAlertHitTx(ctx context.Context, rule model.AlertRule, event *model.LogEvent) (int, error) { - count := 0 - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var err error - count, err = insertAlertHit(tx, rule, event) - return err - }) - return count, err -} - -// insertAlertHit 按 rule→event 锁顺序确认引用存在后插入并统计窗口命中。 -func insertAlertHit(tx *gorm.DB, rule model.AlertRule, event *model.LogEvent) (int, error) { - if err := lockAlertRefs(tx, rule.ID, event.ID); err != nil { - return 0, err - } - if rule.Threshold <= 1 { - return 1, nil - } - now := time.Now() - hit := model.AlertRuleHit{RuleID: rule.ID, LogEventID: event.ID, HitAt: now} - if err := tx.Create(&hit).Error; err != nil { - return 0, fmt.Errorf("create alert rule hit: %w", err) - } - var count int64 - cutoff := now.Add(-time.Duration(rule.WindowMinutes) * time.Minute) - err := tx.Model(&model.AlertRuleHit{}). - Where("rule_id = ? AND hit_at >= ?", rule.ID, cutoff).Count(&count).Error - if err != nil { - return 0, fmt.Errorf("count alert rule hits: %w", err) - } - return int(count), nil -} - -func lockAlertRefs(tx *gorm.DB, ruleID, eventID uint) error { - var rule model.AlertRule - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&rule, ruleID).Error; err != nil { - return fmt.Errorf("lock alert rule %d: %w", ruleID, err) - } - var event model.LogEvent - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&event, eventID).Error; err != nil { - return fmt.Errorf("lock log event %d: %w", eventID, err) - } - return nil -} - -// alertCooldownPass 报告规则是否已过冷却窗口;通过即记录本次发送时刻。 -// 阈值 1 的规则无冷却(每次命中即时告警,与既有云端事件通知一致)。 -func (s *LogEventService) alertCooldownPass(rule model.AlertRule) bool { - if rule.Threshold <= 1 { - return true - } - s.alertMu.Lock() - defer s.alertMu.Unlock() - window := time.Duration(rule.WindowMinutes) * time.Minute - if last, ok := s.alertSentAt[rule.ID]; ok && time.Since(last) < window { - return false - } - if s.alertSentAt == nil { - s.alertSentAt = map[uint]time.Time{} - } - s.alertSentAt[rule.ID] = time.Now() - return true -} - -// ClearAlertCooldown 清除已删除租户规则的进程内冷却状态。 -func (s *LogEventService) ClearAlertCooldown(ruleIDs []uint) { - s.alertMu.Lock() - defer s.alertMu.Unlock() - for _, id := range ruleIDs { - delete(s.alertSentAt, id) - } -} - -// loadEnabledAlertRules 载入启用中的规则;失败时返回空集并记日志(解析主流程照常)。 -func (s *LogEventService) loadEnabledAlertRules(ctx context.Context) []model.AlertRule { - var rules []model.AlertRule - err := s.db.WithContext(ctx).Where("enabled = ?", true).Order("id").Find(&rules).Error - if err != nil { - log.Printf("load alert rules: %v", err) - return nil - } - return rules -} - -// cleanupAlertHits 删除保留期外的命中记录(随 cleanupOnce 周期执行)。 -func (s *LogEventService) cleanupAlertHits(ctx context.Context) { - cutoff := time.Now().Add(-alertHitRetention) - if err := s.db.WithContext(ctx).Where("hit_at < ?", cutoff).Delete(&model.AlertRuleHit{}).Error; err != nil { - log.Printf("cleanup alert hits: %v", err) - } -} diff --git a/internal/service/alertrule_test.go b/internal/service/alertrule_test.go deleted file mode 100644 index dcbe5d3..0000000 --- a/internal/service/alertrule_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "oci-portal/internal/crypto" - "oci-portal/internal/model" -) - -func TestValidateAlertRule(t *testing.T) { - tests := []struct { - name string - rule model.AlertRule - wantErr string - check func(t *testing.T, r model.AlertRule) - }{ - {name: "名称必填", rule: model.AlertRule{Threshold: 1}, wantErr: "名称"}, - {name: "模式非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: "any"}, wantErr: "in/notin"}, - {name: "阈值越界", rule: model.AlertRule{Name: "r", Threshold: 101}, wantErr: "阈值"}, - {name: "阈值>1须带窗口", rule: model.AlertRule{Name: "r", Threshold: 3}, wantErr: "窗口"}, - {name: "IP 非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "300.1.1.1"}, wantErr: "IP"}, - {name: "CIDR 合法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "10.0.0.0/8, 1.2.3.4"}, - check: func(t *testing.T, r model.AlertRule) { - if r.SourceIPs != "10.0.0.0/8,1.2.3.4" { - t.Errorf("SourceIPs = %q, 应去空白归一化", r.SourceIPs) - } - if r.SourceIPMode != alertSourceIPIn { - t.Errorf("SourceIPMode = %q, 应默认 in", r.SourceIPMode) - } - }}, - {name: "事件清单归一化", rule: model.AlertRule{Name: "r", Threshold: 1, EventTypes: " TerminateInstance , CreateApiKey ,"}, - check: func(t *testing.T, r model.AlertRule) { - if r.EventTypes != "TerminateInstance,CreateApiKey" { - t.Errorf("EventTypes = %q", r.EventTypes) - } - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rule := tt.rule - err := validateAlertRule(&rule) - if tt.wantErr == "" { - if err != nil { - t.Fatalf("validateAlertRule: %v", err) - } - if tt.check != nil { - tt.check(t, rule) - } - return - } - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("err = %v, want contains %q", err, tt.wantErr) - } - }) - } -} - -func TestRuleHits(t *testing.T) { - base := model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: alertSourceIPIn} - ev := &model.LogEvent{OciConfigID: 7} - parsed := parsedEvent{ - EventType: "com.oraclecloud.ComputeApi.TerminateInstance", - SourceIP: "203.0.113.8", - ResourceName: "web-server-1", - } - tests := []struct { - name string - mod func(r *model.AlertRule) - p *parsedEvent - want bool - }{ - {name: "空条件全命中", mod: func(r *model.AlertRule) {}, want: true}, - {name: "租户匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 7 }, want: true}, - {name: "租户不匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 8 }, want: false}, - {name: "事件短名命中", mod: func(r *model.AlertRule) { r.EventTypes = "LaunchInstance,TerminateInstance" }, want: true}, - {name: "事件不在清单", mod: func(r *model.AlertRule) { r.EventTypes = "CreateUser" }, want: false}, - {name: "资源子串命中", mod: func(r *model.AlertRule) { r.ResourceMatch = "web-" }, want: true}, - {name: "资源不含", mod: func(r *model.AlertRule) { r.ResourceMatch = "db-" }, want: false}, - {name: "IP in 命中 CIDR", mod: func(r *model.AlertRule) { r.SourceIPs = "203.0.113.0/24" }, want: true}, - {name: "IP in 未命中", mod: func(r *model.AlertRule) { r.SourceIPs = "10.0.0.0/8" }, want: false}, - {name: "IP notin 白名单外告警", mod: func(r *model.AlertRule) { - r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn - }, want: true}, - {name: "IP notin 白名单内不告警", mod: func(r *model.AlertRule) { - r.SourceIPs, r.SourceIPMode = "203.0.113.8", alertSourceIPNotIn - }, want: false}, - {name: "notin 事件缺 IP 不告警", mod: func(r *model.AlertRule) { - r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn - }, p: &parsedEvent{EventType: parsed.EventType}, want: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rule := base - tt.mod(&rule) - p := parsed - if tt.p != nil { - p = *tt.p - } - if got := ruleHits(rule, ev, p); got != tt.want { - t.Errorf("ruleHits = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAlertRuleCRUD(t *testing.T) { - svc, _, _ := newLogEventEnv(t) - ctx := context.Background() - - created, err := svc.CreateAlertRule(ctx, model.AlertRule{Name: "非白名单终止", Enabled: true, Threshold: 1}) - if err != nil { - t.Fatalf("create: %v", err) - } - if created.ID == 0 { - t.Fatal("create 未回填 ID") - } - if _, err := svc.CreateAlertRule(ctx, model.AlertRule{Threshold: 1}); err == nil { - t.Fatal("空名称应校验失败") - } - - created.Enabled = false - created.EventTypes = "TerminateInstance" - updated, err := svc.UpdateAlertRule(ctx, created.ID, created) - if err != nil { - t.Fatalf("update: %v", err) - } - if updated.Enabled || updated.EventTypes != "TerminateInstance" { - t.Fatalf("update 未生效: %+v", updated) - } - - rules, err := svc.ListAlertRules(ctx) - if err != nil || len(rules) != 1 { - t.Fatalf("list = %v, %v", rules, err) - } - if err := svc.DeleteAlertRule(ctx, created.ID); err != nil { - t.Fatalf("delete: %v", err) - } - if rules, _ := svc.ListAlertRules(ctx); len(rules) != 0 { - t.Fatalf("delete 后仍有 %d 条", len(rules)) - } -} - -// auditEventPayload 构造一条含资源与来源 IP 的 CloudEvents 审计消息。 -func auditEventPayload(event, resource, ip string) string { - return fmt.Sprintf(`{"eventType":"com.oraclecloud.ComputeApi.%s","source":"ComputeApi",`+ - `"eventTime":"2026-07-10T08:00:00Z","data":{"resourceName":%q,"identity":{"ipAddress":%q}}}`, - event, resource, ip) -} - -// newAlertNotifyEnv 组装带假 Telegram 通道的告警测试环境。 -func newAlertNotifyEnv(t *testing.T) (*LogEventService, *telegramCapture, func()) { - t.Helper() - svc, db, _ := newLogEventEnv(t) - srv, rec := newFakeTelegram(t, `{"ok":true}`) - cipher, err := crypto.NewCipher("test-data-key") - if err != nil { - t.Fatalf("new cipher: %v", err) - } - settings := NewSettingService(db, cipher) - token := "123456:AAfake" - if err := settings.UpdateTelegram(context.Background(), - UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}); err != nil { - t.Fatalf("update telegram: %v", err) - } - n := NewNotifier(settings) - n.base = srv.URL - svc.SetNotifier(n, settings) - return svc, rec, n.Wait -} - -func TestMatchAlertRulesNotify(t *testing.T) { - svc, rec, wait := newAlertNotifyEnv(t) - ctx := context.Background() - _, err := svc.CreateAlertRule(ctx, model.AlertRule{ - Name: "白名单外终止", Enabled: true, Threshold: 1, - EventTypes: "TerminateInstance", SourceIPs: "10.0.0.0/8", SourceIPMode: alertSourceIPNotIn, - }) - if err != nil { - t.Fatalf("create rule: %v", err) - } - // 命中:白名单外 IP;不命中:白名单内 IP - mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "203.0.113.8")) - mustIngest(t, svc, "m2", auditEventPayload("TerminateInstance", "web-2", "10.1.2.3")) - svc.parseOnce(ctx) - wait() - - alerts := auditAlerts(rec.snapshot()) - joined := strings.Join(alerts, "\n---\n") - if !strings.Contains(joined, "白名单外终止") || !strings.Contains(joined, "web-1") { - t.Fatalf("应收到含规则名与资源的告警,got %q", joined) - } - if strings.Contains(joined, "web-2") { - t.Fatalf("白名单内事件不应告警,got %q", joined) - } -} - -// auditAlerts 过滤出审计告警推送(排除既有 notifyCritical 的云端事件通知)。 -func auditAlerts(texts []string) []string { - var out []string - for _, s := range texts { - if strings.Contains(s, "审计告警") { - out = append(out, s) - } - } - return out -} - -func TestAlertThresholdWindow(t *testing.T) { - svc, rec, wait := newAlertNotifyEnv(t) - ctx := context.Background() - _, err := svc.CreateAlertRule(ctx, model.AlertRule{ - Name: "登录风暴", Enabled: true, Threshold: 3, WindowMinutes: 5, EventTypes: "InteractiveLogin", - }) - if err != nil { - t.Fatalf("create rule: %v", err) - } - for i := 1; i <= 4; i++ { - mustIngest(t, svc, fmt.Sprint("login-", i), - auditEventPayload("InteractiveLogin", "user@x.com", "203.0.113.8")) - } - svc.parseOnce(ctx) - wait() - - alerts := auditAlerts(rec.snapshot()) - if len(alerts) != 1 { - t.Fatalf("窗口内 4 次命中应只告警 1 次(第 3 次触发后冷却),got %d 条: %v", len(alerts), alerts) - } - if !strings.Contains(alerts[0], "3 次") { - t.Errorf("告警文案应含累计次数,got %q", alerts[0]) - } -} - -// TestAlertRuleBadDataDoesNotBlockParse 验证规则表异常不影响解析主流程。 -func TestAlertRuleBadDataDoesNotBlockParse(t *testing.T) { - svc, db, _ := newLogEventEnv(t) - ctx := context.Background() - // 直插一条绕过校验的坏规则(IP 列表非法) - bad := model.AlertRule{Name: "bad", Enabled: true, Threshold: 1, SourceIPs: "not-an-ip"} - if err := db.Create(&bad).Error; err != nil { - t.Fatalf("insert bad rule: %v", err) - } - mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "1.2.3.4")) - svc.parseOnce(ctx) - - var e model.LogEvent - if err := db.First(&e, "message_id = ?", "m1").Error; err != nil { - t.Fatalf("find event: %v", err) - } - if !e.Processed { - t.Fatal("坏规则不应阻塞事件解析") - } -} - -// mustIngest 落一条回传事件,失败即终止测试。 -func mustIngest(t *testing.T, svc *LogEventService, msgID, payload string) { - t.Helper() - if err := svc.Ingest(context.Background(), 1, msgID, []byte(payload), false); err != nil { - t.Fatalf("ingest %s: %v", msgID, err) - } -} - -// TestCleanupAlertHits 验证过期命中记录随清理删除。 -func TestCleanupAlertHits(t *testing.T) { - svc, db, _ := newLogEventEnv(t) - old := model.AlertRuleHit{RuleID: 1, HitAt: time.Now().Add(-8 * 24 * time.Hour)} - fresh := model.AlertRuleHit{RuleID: 1, HitAt: time.Now()} - if err := db.Create(&old).Error; err != nil { - t.Fatalf("insert: %v", err) - } - if err := db.Create(&fresh).Error; err != nil { - t.Fatalf("insert: %v", err) - } - svc.cleanupAlertHits(context.Background()) - var count int64 - db.Model(&model.AlertRuleHit{}).Count(&count) - if count != 1 { - t.Fatalf("清理后应剩 1 条,got %d", count) - } -} - -func TestClearAlertCooldown(t *testing.T) { - svc := NewLogEventService(nil) - rule1 := model.AlertRule{ID: 1, Threshold: 2, WindowMinutes: 10} - rule2 := model.AlertRule{ID: 2, Threshold: 2, WindowMinutes: 10} - if !svc.alertCooldownPass(rule1) || !svc.alertCooldownPass(rule2) { - t.Fatal("首次命中应通过冷却检查") - } - - svc.ClearAlertCooldown([]uint{rule1.ID}) - if !svc.alertCooldownPass(rule1) { - t.Fatal("已清理规则应重新通过冷却检查") - } - if svc.alertCooldownPass(rule2) { - t.Fatal("未清理规则不应通过冷却检查") - } -} - -func TestRecordAlertHitRejectsMissingRefs(t *testing.T) { - tests := []struct { - name string - deleteRule bool - }{ - {name: "规则已删除", deleteRule: true}, - {name: "事件已删除"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testRecordAlertHitMissingRef(t, tt.deleteRule) - }) - } -} - -func testRecordAlertHitMissingRef(t *testing.T, deleteRule bool) { - t.Helper() - svc, db, cfgID := newLogEventEnv(t) - rule := model.AlertRule{Name: "r", OciConfigID: cfgID, Threshold: 2, WindowMinutes: 5} - event := model.LogEvent{OciConfigID: cfgID, MessageID: "m"} - if err := db.Create(&rule).Error; err != nil { - t.Fatalf("create rule: %v", err) - } - if err := db.Create(&event).Error; err != nil { - t.Fatalf("create event: %v", err) - } - var err error - if deleteRule { - err = db.Delete(&rule).Error - } else { - err = db.Delete(&event).Error - } - if err != nil { - t.Fatalf("delete reference: %v", err) - } - if count, ok := svc.recordAlertHit(context.Background(), rule, &event); ok || count != 0 { - t.Fatalf("record missing refs = (%d,%v), want (0,false)", count, ok) - } - var hits int64 - db.Model(&model.AlertRuleHit{}).Count(&hits) - if hits != 0 { - t.Fatalf("orphan alert hits = %d, want 0", hits) - } -} diff --git a/internal/service/anthresponses.go b/internal/service/anthresponses.go index 0f8c4fa..b574bc3 100644 --- a/internal/service/anthresponses.go +++ b/internal/service/anthresponses.go @@ -83,7 +83,7 @@ func anthInputItems(messages []aiwire.AnthMessage) ([]any, error) { func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, err error) { switch b.Type { case "text": - return nil, anthTextPart(role, b.Text), nil + return nil, respTextPart(role, b.Text), nil case "image": part, err = anthImageInput(b.Source) return nil, part, err @@ -100,8 +100,9 @@ func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, } } -// anthTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text)。 -func anthTextPart(role, text string) map[string]any { +// respTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text); +// Anthropic 与 Chat Completions 两条转换链共用。 +func respTextPart(role, text string) map[string]any { if role == "assistant" { return map[string]any{"type": "output_text", "text": text} } @@ -219,8 +220,7 @@ func ResponsesToAnthropic(payload []byte, msgID string) (*aiwire.MessagesRespons out.StopReason = "tool_use" } } - if resp.Status == "incomplete" && resp.IncompleteDetails != nil && - resp.IncompleteDetails.Reason == "max_output_tokens" { + if respTruncated(&resp) { out.StopReason = "max_tokens" } out.Usage = anthUsageFromResp(resp.Usage) @@ -337,8 +337,7 @@ func (st *AnthRespBridge) finishFrom(resp *respPayload) { return } st.usage = anthUsageFromResp(resp.Usage) - if resp.Status == "incomplete" && resp.IncompleteDetails != nil && - resp.IncompleteDetails.Reason == "max_output_tokens" { + if respTruncated(resp) { st.stopReason = "max_tokens" } } diff --git a/internal/service/chatresponses.go b/internal/service/chatresponses.go new file mode 100644 index 0000000..84bc8ef --- /dev/null +++ b/internal/service/chatresponses.go @@ -0,0 +1,360 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "oci-portal/internal/aiwire" +) + +// OpenAI Chat Completions ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。 +// 端点定位 Tier 2 兼容层(承接只会说 CC 的存量客户端),机制与 anthresponses.go +// 同构。语义损失(README 披露):stop / seed / n / penalty 等无对应字段,忽略; +// 上游 reasoning 输出项与增量事件丢弃;非 function 工具类型拒绝。 + +// ChatToResponsesBody 把 Chat Completions 请求转为直通 body(强制 store:false)。 +func ChatToResponsesBody(req aiwire.ChatRequest) ([]byte, error) { + input, instructions, err := chatInputItems(req.Messages) + if err != nil { + return nil, err + } + body := map[string]any{"model": req.Model, "input": input, "store": false} + if instructions != "" { + body["instructions"] = instructions + } + chatSampling(body, req) + if err := chatBodyTools(body, req); err != nil { + return nil, err + } + if tf := chatTextFormat(req.ResponseFormat); tf != nil { + body["text"] = map[string]any{"format": tf} + } + if req.ReasoningEffort != "" { + body["reasoning"] = map[string]string{"effort": strings.ToLower(req.ReasoningEffort)} + } + if req.Stream { + body["stream"] = true + } + return json.Marshal(body) +} + +// chatSampling 装配采样与输出预算;max_completion_tokens 优先于已弃用的 max_tokens。 +func chatSampling(body map[string]any, req aiwire.ChatRequest) { + if req.MaxCompletionTokens != nil { + body["max_output_tokens"] = *req.MaxCompletionTokens + } else if req.MaxTokens != nil { + body["max_output_tokens"] = *req.MaxTokens + } + if req.Temperature != nil { + body["temperature"] = *req.Temperature + } + if req.TopP != nil { + body["top_p"] = *req.TopP + } + if req.ParallelToolCalls != nil { + body["parallel_tool_calls"] = *req.ParallelToolCalls + } +} + +// chatBodyTools 装配工具与 tool_choice;非 function 工具类型拒绝 +// (web_search 等新能力仅在 Responses / Messages 端点供给)。 +func chatBodyTools(body map[string]any, req aiwire.ChatRequest) error { + if len(req.Tools) == 0 { + return nil + } + tools := make([]map[string]any, 0, len(req.Tools)) + for _, t := range req.Tools { + if t.Type != "function" { + return fmt.Errorf("不支持的工具类型 %q:该端点仅支持 function 工具", t.Type) + } + tools = append(tools, map[string]any{"type": "function", "name": t.Function.Name, + "description": t.Function.Description, "parameters": t.Function.Parameters}) + } + body["tools"] = tools + if tc := chatRespToolChoice(req.ToolChoice); tc != nil { + body["tool_choice"] = tc + } + return nil +} + +// chatInputItems 把消息序列展开为 Responses input 项与 instructions: +// system/developer 文本聚为 instructions;tool 消息为 function_call_output 项; +// 其余消息经 chatMessageItems 展开,保持相对顺序。 +func chatInputItems(messages []aiwire.ChatMessage) ([]any, string, error) { + items := make([]any, 0, len(messages)) + var sys []string + for _, m := range messages { + switch m.Role { + case "system", "developer": + if txt := m.Content.JoinText(); txt != "" { + sys = append(sys, txt) + } + case "tool": + items = append(items, map[string]any{"type": "function_call_output", + "call_id": m.ToolCallID, "output": m.Content.JoinText()}) + default: + msgItems, err := chatMessageItems(m) + if err != nil { + return nil, "", err + } + items = append(items, msgItems...) + } + } + return items, strings.Join(sys, "\n\n"), nil +} + +// chatMessageItems 把 user/assistant 消息转为 message 项;assistant 的 +// tool_calls 追加为独立 function_call 项(内容在前,与原语序一致)。 +func chatMessageItems(m aiwire.ChatMessage) ([]any, error) { + parts, err := chatContentParts(m.Role, m.Content) + if err != nil { + return nil, err + } + items := make([]any, 0, 1+len(m.ToolCalls)) + if len(parts) > 0 { + items = append(items, map[string]any{"role": m.Role, "content": parts}) + } + for _, tc := range m.ToolCalls { + items = append(items, map[string]any{"type": "function_call", "call_id": tc.ID, + "name": tc.Function.Name, "arguments": tc.Function.Arguments}) + } + return items, nil +} + +// chatContentParts 把消息内容转为 Responses 部件(文本按角色定型,图片转 input_image)。 +func chatContentParts(role string, c aiwire.Content) ([]map[string]any, error) { + if !c.IsArray { + if c.Text == "" { + return nil, nil + } + return []map[string]any{respTextPart(role, c.Text)}, nil + } + parts := make([]map[string]any, 0, len(c.Parts)) + for _, p := range c.Parts { + switch p.Type { + case "", "text": + parts = append(parts, respTextPart(role, p.Text)) + case "image_url": + if p.ImageURL == nil || p.ImageURL.URL == "" { + return nil, fmt.Errorf("image_url 块缺少 url") + } + // detail 不透传:兼容面对该字段的支持无法证实(实测期间上游视觉请求不稳, + // 无从归因),它仅是质量提示,忽略合法且消除一个风险轴(README 披露) + parts = append(parts, map[string]any{"type": "input_image", "image_url": p.ImageURL.URL}) + default: + return nil, ErrAiUnsupportedBlock + } + } + return parts, nil +} + +// chatRespToolChoice 映射 tool_choice:string 形态(auto/none/required)原样; +// {"type":"function","function":{"name":N}} 拍平为 Responses 具名形态;其余忽略。 +func chatRespToolChoice(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + var s string + if json.Unmarshal(raw, &s) == nil { + switch s { + case "auto", "none", "required": + return s + } + return nil + } + var tc struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if json.Unmarshal(raw, &tc) != nil || tc.Type != "function" || tc.Function.Name == "" { + return nil + } + return map[string]string{"type": "function", "name": tc.Function.Name} +} + +// chatTextFormat 把 response_format 拍平为 text.format(json_schema 提升嵌套字段)。 +func chatTextFormat(rf *aiwire.ResponseFormat) map[string]any { + if rf == nil || rf.Type == "" || rf.Type == "text" { + return nil + } + format := map[string]any{"type": rf.Type} + if rf.Type != "json_schema" || len(rf.JSONSchema) == 0 { + return format + } + var js struct { + Name string `json:"name"` + Schema json.RawMessage `json:"schema"` + Strict *bool `json:"strict"` + } + if json.Unmarshal(rf.JSONSchema, &js) != nil { + return format + } + if js.Name != "" { + format["name"] = js.Name + } + if len(js.Schema) > 0 { + format["schema"] = js.Schema + } + if js.Strict != nil { + format["strict"] = *js.Strict + } + return format +} + +// ResponsesToChat 把直通非流式响应转为 Chat Completions 响应(reasoning 项丢弃)。 +func ResponsesToChat(payload []byte, id string, created int64) (*aiwire.ChatResponse, error) { + var resp respPayload + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("解析上游响应: %w", err) + } + msg := aiwire.ChatMessage{Role: "assistant"} + var text strings.Builder + for _, item := range resp.Output { + switch item.Type { + case "message": + for _, part := range item.Content { + if part.Type == "output_text" { + text.WriteString(part.Text) + } + } + case "function_call": + msg.ToolCalls = append(msg.ToolCalls, aiwire.ToolCall{ID: item.CallID, Type: "function", + Function: aiwire.FunctionCall{Name: item.Name, Arguments: item.Arguments}}) + } + } + msg.Content = aiwire.NewTextContent(text.String()) + choice := aiwire.Choice{Index: 0, Message: msg, + FinishReason: chatFinishReason(&resp, len(msg.ToolCalls) > 0)} + return &aiwire.ChatResponse{ID: id, Object: "chat.completion", Created: created, + Model: resp.Model, Choices: []aiwire.Choice{choice}, Usage: usageFromResp(resp.Usage)}, nil +} + +// chatFinishReason 映射终态:截断优先 → length;含工具调用 → tool_calls;默认 stop。 +func chatFinishReason(resp *respPayload, hasTools bool) string { + if respTruncated(resp) { + return "length" + } + if hasTools { + return "tool_calls" + } + return "stop" +} + +// respTruncated 报告直通响应是否因 max_output_tokens 截断。 +func respTruncated(resp *respPayload) bool { + return resp != nil && resp.Status == "incomplete" && resp.IncompleteDetails != nil && + resp.IncompleteDetails.Reason == "max_output_tokens" +} + +// ---- Chat Completions 流式桥:直通 SSE 事件 → chat.completion.chunk 序列 ---- + +// ChatRespBridge 把直通 SSE 事件流桥接为 chunk 序列:首个增量补 role,文本与 +// 工具增量按 OpenAI 形态输出;reasoning 系列事件丢弃;[DONE] 由传输层写出。 +type ChatRespBridge struct { + id, model string + created int64 + includeUsage bool + started bool + toolIndex int + inTool bool + finish string + usage *aiwire.Usage +} + +// NewChatRespBridge 构造桥;includeUsage 即 stream_options.include_usage。 +func NewChatRespBridge(id, model string, created int64, includeUsage bool) *ChatRespBridge { + return &ChatRespBridge{id: id, model: model, created: created, + includeUsage: includeUsage, toolIndex: -1, finish: "stop"} +} + +// Feed 消费一行 SSE data JSON,返回应立即写出的 chunk。 +func (b *ChatRespBridge) Feed(data []byte) []aiwire.ChatChunk { + var ev respStreamEvent + if json.Unmarshal(data, &ev) != nil { + return nil + } + switch ev.Type { + case "response.output_text.delta": + b.inTool = false + return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{Content: ev.Delta}), nil)} + case "response.output_item.added": + return b.toolOpen(ev.Item) + case "response.function_call_arguments.delta": + return b.argsChunk(ev.Delta) + case "response.completed", "response.incomplete", "response.failed": + b.finishFrom(ev.Response) + } + return nil +} + +// chunk 装配一条单 choice 增量事件。 +func (b *ChatRespBridge) chunk(delta aiwire.Delta, finish *string) aiwire.ChatChunk { + return aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", Created: b.created, + Model: b.model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}} +} + +// deltaWithRole 给首个增量补 role(OpenAI 首块携带 role 语义)。 +func (b *ChatRespBridge) deltaWithRole(d aiwire.Delta) aiwire.Delta { + if !b.started { + b.started = true + d.Role = "assistant" + } + return d +} + +// toolOpen 在新 function_call 输出项开始时发 id/name 增量并推进聚合 index; +// 非工具输出项只复位增量目标。 +func (b *ChatRespBridge) toolOpen(item *respOutputItem) []aiwire.ChatChunk { + if item == nil || item.Type != "function_call" { + b.inTool = false + return nil + } + b.toolIndex++ + b.inTool = true + b.finish = "tool_calls" + d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, ID: item.CallID, + Type: "function", Function: aiwire.FunctionCallDelta{Name: item.Name}}}} + return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(d), nil)} +} + +// argsChunk 输出当前工具的实参增量(仅在工具输出项进行中)。 +func (b *ChatRespBridge) argsChunk(delta string) []aiwire.ChatChunk { + if !b.inTool { + return nil + } + d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, + Function: aiwire.FunctionCallDelta{Arguments: delta}}}} + return []aiwire.ChatChunk{b.chunk(d, nil)} +} + +// finishFrom 记录终态 usage 与截断语义(max_output_tokens → length)。 +func (b *ChatRespBridge) finishFrom(resp *respPayload) { + if resp == nil { + return + } + b.usage = usageFromResp(resp.Usage) + if respTruncated(resp) { + b.finish = "length" + } +} + +// Finish 在上游流结束后收尾:终块带 finish_reason;include_usage 时追加 usage 块 +// (choices 为空数组,OpenAI 规范形态;上游未给 usage 时输出零值)。 +func (b *ChatRespBridge) Finish() []aiwire.ChatChunk { + chunks := []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{}), &b.finish)} + if !b.includeUsage { + return chunks + } + usage := b.usage + if usage == nil { + usage = &aiwire.Usage{} + } + return append(chunks, aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", + Created: b.created, Model: b.model, Choices: []aiwire.ChunkChoice{}, Usage: usage}) +} + +// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。 +func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage } diff --git a/internal/service/chatresponses_test.go b/internal/service/chatresponses_test.go new file mode 100644 index 0000000..68e96f4 --- /dev/null +++ b/internal/service/chatresponses_test.go @@ -0,0 +1,301 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "oci-portal/internal/aiwire" +) + +func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest { + t.Helper() + var req aiwire.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("解析请求: %v", err) + } + return req +} + +func mustChatBody(t *testing.T, raw string) map[string]any { + t.Helper() + payload, err := ChatToResponsesBody(mustChatReq(t, raw)) + if err != nil { + t.Fatalf("ChatToResponsesBody: %v", err) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return body +} + +// TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。 +func TestChatToResponsesBody(t *testing.T) { + body := mustChatBody(t, `{ + "model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128, + "temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true, + "reasoning_effort": "HIGH", + "messages": [ + {"role": "system", "content": "你是助手"}, + {"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]}, + {"role": "user", "content": "东京天气?"}, + {"role": "assistant", "content": "查询中", "tool_calls": [ + {"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}} + ]}, + {"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"}, + {"role": "user", "content": "继续"} + ], + "tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}], + "tool_choice": "required" + }`) + if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true { + t.Fatalf("顶层字段装配错误: %v", body) + } + if body["max_output_tokens"] != float64(128) { + t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"]) + } + if body["instructions"] != "你是助手\n\n简洁作答" { + t.Fatalf("instructions = %v", body["instructions"]) + } + if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false { + t.Fatalf("采样字段装配错误: %v", body) + } + if body["tool_choice"] != "required" { + t.Fatalf("tool_choice = %v", body["tool_choice"]) + } + if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" { + t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"]) + } + kinds := chatInputKinds(t, body) + want := "message/user,message/assistant,function_call,function_call_output,message/user" + if kinds != want { + t.Fatalf("input 顺序 = %s, want %s", kinds, want) + } + tools, _ := body["tools"].([]any) + tool, _ := tools[0].(map[string]any) + if tool["type"] != "function" || tool["name"] != "get_weather" { + t.Fatalf("工具应拍平为 Responses 形态: %v", tool) + } +} + +func chatInputKinds(t *testing.T, body map[string]any) string { + t.Helper() + input, _ := body["input"].([]any) + kinds := make([]string, 0, len(input)) + for _, it := range input { + m := it.(map[string]any) + if ty, ok := m["type"].(string); ok { + kinds = append(kinds, ty) + } else { + kinds = append(kinds, "message/"+m["role"].(string)) + } + } + return strings.Join(kinds, ",") +} + +// TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。 +func TestChatToResponsesBodyContent(t *testing.T) { + body := mustChatBody(t, `{"model":"m","messages":[ + {"role":"user","content":[ + {"type":"text","text":"看图"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}, + {"role":"assistant","content":"这是猫"}]}`) + raw, _ := json.Marshal(body["input"]) + if !strings.Contains(string(raw), `"input_image"`) || + !strings.Contains(string(raw), "data:image/png;base64,QUJD") { + t.Fatalf("图片应转 input_image: %s", raw) + } + if !strings.Contains(string(raw), `"output_text"`) { + t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw) + } + + for name, msg := range map[string]string{ + "audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`, + "图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`, + } { + raw := `{"model":"m","messages":[` + msg + `]}` + if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil { + t.Errorf("%s 应拒绝", name) + } + } + badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"web_search","function":{}}]}`) + if _, err := ChatToResponsesBody(badTool); err == nil { + t.Error("非 function 工具类型应拒绝") + } +} + +// TestChatRespToolChoice 断言 tool_choice 各形态映射。 +func TestChatRespToolChoice(t *testing.T) { + cases := []struct { + name, raw string + want any + }{ + {"auto", `"auto"`, "auto"}, + {"none", `"none"`, "none"}, + {"required", `"required"`, "required"}, + {"具名 function", `{"type":"function","function":{"name":"f1"}}`, + map[string]string{"type": "function", "name": "f1"}}, + {"未知 string", `"whatever"`, nil}, + {"缺 name", `{"type":"function","function":{}}`, nil}, + {"空", ``, nil}, + } + for _, tc := range cases { + got := chatRespToolChoice(json.RawMessage(tc.raw)) + if gotMap, ok := got.(map[string]string); ok { + wantMap, _ := tc.want.(map[string]string) + if wantMap == nil || gotMap["name"] != wantMap["name"] { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + continue + } + if got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} + +// TestChatTextFormat 断言 response_format 拍平映射。 +func TestChatTextFormat(t *testing.T) { + if got := chatTextFormat(nil); got != nil { + t.Errorf("nil 应不下发: %v", got) + } + if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil { + t.Errorf("text 应不下发: %v", got) + } + if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" { + t.Errorf("json_object: %v", got) + } + got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema", + JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)}) + if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true { + t.Errorf("json_schema 应提升嵌套字段: %v", got) + } + if _, ok := got["schema"]; !ok { + t.Errorf("schema 缺失: %v", got) + } +} + +// TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。 +func TestResponsesToChat(t *testing.T) { + payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[ + {"type":"reasoning","summary":[]}, + {"type":"message","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]}, + {"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}], + "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`) + out, err := ResponsesToChat(payload, "chatcmpl-1", 1700000000) + if err != nil { + t.Fatalf("ResponsesToChat: %v", err) + } + if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 { + t.Fatalf("响应骨架错误: %+v", out) + } + msg := out.Choices[0].Message + if msg.Role != "assistant" || msg.Content.JoinText() != "你好" { + t.Fatalf("content 装配错误: %+v", msg) + } + if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" || + msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` { + t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls) + } + if out.Choices[0].FinishReason != "tool_calls" { + t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason) + } + if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 4 { + t.Fatalf("usage = %+v", out.Usage) + } + + trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}, + "output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`) + out2, err := ResponsesToChat(trunc, "chatcmpl-2", 1) + if err != nil || out2.Choices[0].FinishReason != "length" { + t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err) + } + if out2.Usage != nil { + t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage) + } +} + +func chatChunkShapes(chunks []aiwire.ChatChunk) string { + kinds := make([]string, 0, len(chunks)) + for _, ch := range chunks { + switch { + case len(ch.Choices) == 0: + kinds = append(kinds, "usage") + case ch.Choices[0].FinishReason != nil: + kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason) + case len(ch.Choices[0].Delta.ToolCalls) > 0: + kinds = append(kinds, "tool") + case ch.Choices[0].Delta.Role != "": + kinds = append(kinds, "role+text") + default: + kinds = append(kinds, "text") + } + } + return strings.Join(kinds, ",") +} + +// TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。 +func TestChatRespBridge(t *testing.T) { + b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true) + var chunks []aiwire.ChatChunk + feed := func(lines ...string) { + for _, l := range lines { + chunks = append(chunks, b.Feed([]byte(l))...) + } + } + feed(`{"type":"response.created","response":{"model":"m1"}}`, + `{"type":"response.reasoning_text.delta","delta":"思考中"}`, + `{"type":"response.output_text.delta","delta":"你"}`, + `{"type":"response.output_text.delta","delta":"好"}`, + `{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`, + `{"type":"response.function_call_arguments.delta","delta":"{\"a\":"}`, + `{"type":"response.function_call_arguments.delta","delta":"1}"}`, + `{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`) + chunks = append(chunks, b.Finish()...) + + got := chatChunkShapes(chunks) + want := "role+text,text,tool,tool,tool,finish:tool_calls,usage" + if got != want { + t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want) + } + if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" { + t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta) + } + tool := chunks[2].Choices[0].Delta.ToolCalls[0] + if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" { + t.Fatalf("工具首块 = %+v", tool) + } + args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments + + chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments + if args != `{"a":1}` { + t.Fatalf("实参增量聚合 = %s", args) + } + last := chunks[len(chunks)-1] + if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 { + t.Fatalf("usage 块 = %+v", last) + } + if b.Usage().TotalTokens != 12 { + t.Fatalf("Usage() = %+v", b.Usage()) + } +} + +// TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。 +func TestChatRespBridgeVariants(t *testing.T) { + noUsage := NewChatRespBridge("c", "m", 1, false) + noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`)) + noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`)) + if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" { + t.Errorf("截断且不带 usage 块: %s", got) + } + + empty := NewChatRespBridge("c", "m", 1, false) + fin := empty.Finish() + if got := chatChunkShapes(fin); got != "finish:stop" { + t.Errorf("空流收尾 = %s", got) + } + if fin[0].Choices[0].Delta.Role != "assistant" { + t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta) + } +} diff --git a/internal/service/logevent.go b/internal/service/logevent.go index b66be80..2254aab 100644 --- a/internal/service/logevent.go +++ b/internal/service/logevent.go @@ -57,9 +57,6 @@ type LogEventService struct { relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认 relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认 - - alertMu sync.Mutex // 保护告警规则冷却表 - alertSentAt map[uint]time.Time // 规则 ID → 上次告警时刻(阈值型规则冷却) } // NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。 @@ -373,20 +370,18 @@ func (s *LogEventService) parseOnce(ctx context.Context) { if len(events) == 0 { return } - rules := s.loadEnabledAlertRules(ctx) for i := range events { - s.processLogEvent(ctx, &events[i], rules) + s.processLogEvent(ctx, &events[i]) } } // processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。 -func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent, rules []model.AlertRule) { +func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent) { parsed := parseLogEvent([]byte(event.Payload)) if !s.updateParsedEvent(ctx, event, parsed) { return } s.notifyCritical(ctx, event, parsed) - s.matchAlertRules(ctx, rules, event, parsed) } // updateParsedEvent 用条件 UPDATE 禁止 Save 在删除后隐式重建事件。 @@ -413,26 +408,49 @@ func (s *LogEventService) updateParsedEvent(ctx context.Context, event *model.Lo // onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段; // 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。 type onsEnvelope struct { - EventType string `json:"eventType"` - Type string `json:"type"` - Source string `json:"source"` - EventTime string `json:"eventTime"` - ResourceName string `json:"resourceName"` - Data json.RawMessage `json:"data"` - Identity *onsIdentity `json:"identity"` + EventType string `json:"eventType"` + Type string `json:"type"` + Source string `json:"source"` + EventTime string `json:"eventTime"` + ResourceName string `json:"resourceName"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + Identity *onsIdentity `json:"identity"` + AdditionalDetails *onsAddDetails `json:"additionalDetails"` + StateChange *onsStateChange `json:"stateChange"` } // onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。 type onsIdentity struct { - IPAddress string `json:"ipAddress"` + IPAddress string `json:"ipAddress"` + PrincipalName string `json:"principalName"` } -// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。 +// onsAddDetails 是 IDCS 登录类事件 data.additionalDetails 的补充字段; +// AuditEventMapValue 为嵌套的 JSON 字符串(含 eventId 成败与失败原因)。 +type onsAddDetails struct { + ActorName string `json:"actorName"` + ClientIP string `json:"clientIp"` + AuditEventMapValue string `json:"auditEventMapValue"` +} + +// onsStateChange 承载 Audit v2 的资源变更快照;description 供策略类事件推送文案。 +type onsStateChange struct { + Current struct { + Description string `json:"description"` + } `json:"current"` +} + +// parsedEvent 是从消息原文提取的展示字段集;ResourceName/Actor/Outcome/Detail +// 仅供 P2 推送文案,不落库。 type parsedEvent struct { EventType string Source string SourceIP string ResourceName string + Actor string // 操作者(identity.principalName,登录事件回退 actorName) + Outcome string // 成功 / 失败 / 空(判读不出) + Detail string // 补充说明(策略描述、登录失败原因等) EventTime *time.Time } @@ -455,7 +473,7 @@ func parseLogEvent(payload []byte) parsedEvent { return envelopeFields(env) } -// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。 +// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 等在内层)。 func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope { if outer.EventType == "" { outer.EventType = inner.EventType @@ -472,13 +490,22 @@ func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope { if outer.ResourceName == "" { outer.ResourceName = inner.ResourceName } + if outer.Message == "" { + outer.Message = inner.Message + } if outer.Identity == nil { outer.Identity = inner.Identity } + if outer.AdditionalDetails == nil { + outer.AdditionalDetails = inner.AdditionalDetails + } + if outer.StateChange == nil { + outer.StateChange = inner.StateChange + } return outer } -// envelopeFields 收敛字段别名并解析事件时间。 +// envelopeFields 收敛字段别名并解析事件时间与推送用补充字段。 func envelopeFields(env onsEnvelope) parsedEvent { eventType := env.EventType if eventType == "" { @@ -490,19 +517,81 @@ func envelopeFields(env onsEnvelope) parsedEvent { eventTime = &ts } } - ip := "" - if env.Identity != nil { - ip = env.Identity.IPAddress - } + outcome, detail := envOutcome(env) return parsedEvent{ EventType: clip(eventType, 128), Source: clip(env.Source, 64), - SourceIP: clip(ip, 64), + SourceIP: clip(envIP(env), 64), ResourceName: clip(env.ResourceName, 128), + Actor: clipRunes(envActor(env), 64), + Outcome: outcome, + Detail: clipRunes(detail, 200), EventTime: eventTime, } } +// envIP 取发起方 IP:Audit 的 identity.ipAddress,登录事件回退 additionalDetails.clientIp。 +func envIP(env onsEnvelope) string { + if env.Identity != nil && env.Identity.IPAddress != "" { + return env.Identity.IPAddress + } + if env.AdditionalDetails != nil { + return env.AdditionalDetails.ClientIP + } + return "" +} + +// envActor 取操作者:Audit 的 identity.principalName,登录事件回退 additionalDetails.actorName。 +func envActor(env onsEnvelope) string { + if env.Identity != nil && env.Identity.PrincipalName != "" { + return env.Identity.PrincipalName + } + if env.AdditionalDetails != nil { + return env.AdditionalDetails.ActorName + } + return "" +} + +// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余 +// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。 +func envOutcome(env onsEnvelope) (outcome, detail string) { + if env.StateChange != nil { + detail = env.StateChange.Current.Description + } + if env.AdditionalDetails != nil && env.AdditionalDetails.AuditEventMapValue != "" { + return ssoOutcome(env.AdditionalDetails.AuditEventMapValue, detail) + } + switch { + case strings.HasSuffix(env.Message, " succeeded"): + outcome = "成功" + case strings.HasSuffix(env.Message, " failed"): + outcome = "失败" + } + return outcome, detail +} + +// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败, +// 失败时以其 message 作为原因说明。 +func ssoOutcome(raw, detail string) (string, string) { + var ev struct { + EventID string `json:"eventId"` + Message string `json:"message"` + } + if json.Unmarshal([]byte(raw), &ev) != nil { + return "", detail + } + switch { + case strings.Contains(ev.EventID, "success"): + return "成功", detail + case strings.Contains(ev.EventID, "failure"): + if ev.Message != "" { + detail = ev.Message + } + return "失败", detail + } + return "", detail +} + // clip 按模型列宽截断解析出的字段。 func clip(s string, max int) string { if len(s) > max { @@ -511,6 +600,15 @@ func clip(s string, max int) string { return s } +// clipRunes 按字符数截断(通知文案用,中文安全)。 +func clipRunes(s string, max int) string { + r := []rune(s) + if len(r) > max { + return string(r[:max]) + } + return s +} + // StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。 func (s *LogEventService) StartCleanup(ctx context.Context) { s.wg.Add(1) @@ -535,7 +633,6 @@ func (s *LogEventService) cleanupOnce(ctx context.Context) { if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil { log.Printf("log event cleanup: %v", err) } - s.cleanupAlertHits(ctx) } // cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。 diff --git a/internal/service/logevent_test.go b/internal/service/logevent_test.go index 465823f..3737f7f 100644 --- a/internal/service/logevent_test.go +++ b/internal/service/logevent_test.go @@ -30,8 +30,7 @@ func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) { t.Fatalf("db handle: %v", err) } sqlDB.SetMaxOpenConns(1) - if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}, - &model.AlertRule{}, &model.AlertRuleHit{}); err != nil { + if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil { t.Fatalf("auto migrate: %v", err) } cfg := model.OciConfig{Alias: "测试租户"} @@ -240,12 +239,15 @@ func TestIngestRejectsUnknownConfig(t *testing.T) { func TestParseLogEvent(t *testing.T) { ts := "2026-07-07T08:00:00Z" tests := []struct { - name string - payload string - wantType string - wantSource string - wantIP string - wantTime bool + name string + payload string + wantType string + wantSource string + wantIP string + wantTime bool + wantActor string + wantOutcome string + wantDetail string }{ { name: "CloudEvents 单事件", @@ -283,6 +285,29 @@ func TestParseLogEvent(t *testing.T) { payload: `{"eventType":"x","data":{"identity":{}}}`, wantType: "x", }, + { + name: "Audit v2 提取操作者成败与策略描述", + payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{ + "identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"}, + "message":"ociportal-logs-sch CreatePolicy succeeded", + "stateChange":{"current":{"description":"允许 Connector 发布到 ONS"}}}}`, + wantType: "com.oraclecloud.identityControlPlane.CreatePolicy", wantIP: "129.159.43.9", + wantActor: "Alfonso Garcia", wantOutcome: "成功", wantDetail: "允许 Connector 发布到 ONS", + }, + { + name: "IDCS 登录失败提取用户 IP 与原因", + payload: `{"eventType":"com.oraclecloud.IdentitySignOn.InteractiveLogin","data":{ + "additionalDetails":{"actorName":"oci","clientIp":"155.117.82.111", + "auditEventMapValue":"{\"eventId\":\"sso.authentication.failure\",\"message\":\"Authentication failure : incorrect password.\"}"}}}`, + wantType: "com.oraclecloud.IdentitySignOn.InteractiveLogin", wantIP: "155.117.82.111", + wantActor: "oci", wantOutcome: "失败", wantDetail: "Authentication failure : incorrect password.", + }, + { + name: "message failed 后缀判失败", + payload: `{"eventType":"x","data":{"message":"vm TerminateInstance failed"}}`, + wantType: "x", + wantOutcome: "失败", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -291,6 +316,10 @@ func TestParseLogEvent(t *testing.T) { t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)", got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP) } + if got.Actor != tt.wantActor || got.Outcome != tt.wantOutcome || got.Detail != tt.wantDetail { + t.Errorf("推送字段 = (%q,%q,%q), want (%q,%q,%q)", + got.Actor, got.Outcome, got.Detail, tt.wantActor, tt.wantOutcome, tt.wantDetail) + } if (got.EventTime != nil) != tt.wantTime { t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime) } diff --git a/internal/service/logrelay.go b/internal/service/logrelay.go index 54b38b9..4b3939e 100644 --- a/internal/service/logrelay.go +++ b/internal/service/logrelay.go @@ -322,12 +322,34 @@ func relayEventShortName(eventType string) string { } // criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。 +// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。 func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) { name := relayEventShortName(p.EventType) if name == "" || !relayCriticalSet[name] { return nil, false } - return map[string]string{"tenant": alias, "event": name, "resource": p.ResourceName}, true + return map[string]string{ + "tenant": alias, "event": name, + "resource": orDash(p.ResourceName), "actor": orDash(p.Actor), + "ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome), + "detail": detailLine(p.Detail), + }, true +} + +// orDash 空值兜底为 —,避免模板出现悬空标签。 +func orDash(v string) string { + if v == "" { + return "—" + } + return v +} + +// detailLine 把补充说明包装为独立行;为空时不产生多余空行。 +func detailLine(v string) string { + if v == "" { + return "" + } + return "\n" + v } // notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。 diff --git a/internal/service/logrelay_test.go b/internal/service/logrelay_test.go index 9b221c6..6fa15ab 100644 --- a/internal/service/logrelay_test.go +++ b/internal/service/logrelay_test.go @@ -313,26 +313,36 @@ func TestRelayReady(t *testing.T) { func TestCriticalEventText(t *testing.T) { tests := []struct { - name string - event parsedEvent - wantOK bool - wantText string + name string + event parsedEvent + wantOK bool + want map[string]string }{ { - name: "实例终止命中", - event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", ResourceName: "vm-1"}, - wantOK: true, - wantText: "☁️ 云端事件:免费01 TerminateInstance vm-1", + name: "实例终止含操作者与成败", + event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", + ResourceName: "vm-1", Actor: "demo@example.com", SourceIP: "203.0.113.8", Outcome: "成功"}, + wantOK: true, + want: map[string]string{"event": "TerminateInstance", "resource": "vm-1", + "actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""}, }, { - name: "无资源名省略尾段", - event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"}, - wantOK: true, - wantText: "☁️ 云端事件:免费01 CreateApiKey", + name: "字段缺失兜底破折号", + event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"}, + wantOK: true, + want: map[string]string{"event": "CreateApiKey", "resource": "—", + "actor": "—", "ip": "—", "outcome": "—", "detail": ""}, + }, + { + name: "补充说明独立成行", + event: parsedEvent{EventType: "CreatePolicy", Detail: "允许发布到 ONS Topic"}, + wantOK: true, + want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"}, }, {name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false}, {name: "空类型不推", event: parsedEvent{}, wantOK: false}, - {name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true, wantText: "☁️ 云端事件:免费01 LaunchInstance"}, + {name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true, + want: map[string]string{"event": "LaunchInstance"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -340,13 +350,15 @@ func TestCriticalEventText(t *testing.T) { if ok != tt.wantOK { t.Fatalf("ok = %v, want %v", ok, tt.wantOK) } - if ok { - got := "☁️ 云端事件:" + vars["tenant"] + " " + vars["event"] - if vars["resource"] != "" { - got += " " + vars["resource"] - } - if got != tt.wantText { - t.Errorf("vars 渲染 = %q, want %q", got, tt.wantText) + if !ok { + return + } + if vars["tenant"] != "免费01" { + t.Errorf("tenant = %q, want 免费01", vars["tenant"]) + } + for k, want := range tt.want { + if vars[k] != want { + t.Errorf("%s = %q, want %q", k, vars[k], want) } } }) diff --git a/internal/service/notifytpl.go b/internal/service/notifytpl.go index 0275c79..4bda130 100644 --- a/internal/service/notifytpl.go +++ b/internal/service/notifytpl.go @@ -21,7 +21,6 @@ var notifyTplOrder = []string{ "task_fail", "task_recover", "snatch_success", "tenant_dead", "task_stop", "login_lock", "model_deprecated", "log_event_instance", "log_event_identity", "log_event_policy", "log_event_region", "log_event_login", - "audit_alert", } var notifyTplDefs = map[string]notifyTplDef{ @@ -73,40 +72,40 @@ var notifyTplDefs = map[string]notifyTplDef{ }, "log_event_instance": { Label: "实例生命周期", - Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", - Vars: []string{"tenant", "event", "resource"}, - Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1"}, + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}", + Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"}, + Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1", + "actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""}, }, "log_event_identity": { Label: "用户与凭据", - Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", - Vars: []string{"tenant", "event", "resource"}, - Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo"}, + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}", + Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"}, + Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo", + "actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""}, }, "log_event_policy": { Label: "策略变更", - Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", - Vars: []string{"tenant", "event", "resource"}, - Sample: map[string]string{"tenant": "免费01", "event": "UpdatePolicy", "resource": "admin-policy"}, + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}", + Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"}, + Sample: map[string]string{"tenant": "免费01", "event": "CreatePolicy", "resource": "admin-policy", + "actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", + "detail": "\n允许 Service Connector 发布到 ONS Topic"}, }, "log_event_region": { Label: "区域订阅", - Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", - Vars: []string{"tenant", "event", "resource"}, - Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1"}, + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}", + Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"}, + Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1", + "actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""}, }, "log_event_login": { Label: "控制台登录", - Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", - Vars: []string{"tenant", "event", "resource"}, - Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "demo@example.com"}, - }, - "audit_alert": { - Label: "审计告警", - Default: "🔔 审计告警:{{rule}}\n租户 {{tenant}} · {{event}} {{resource}}\n来源 {{ip}} · 窗口内 {{count}} 次", - Vars: []string{"rule", "tenant", "event", "resource", "ip", "count"}, - Sample: map[string]string{"rule": "非白名单终止实例", "tenant": "免费01", - "event": "TerminateInstance", "resource": "web-server-1", "ip": "203.0.113.8", "count": "1"}, + Default: "☁️ 云端事件:{{tenant}} {{event}} · {{outcome}}\n用户 {{actor}} · 登录 IP {{ip}}{{detail}}", + Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"}, + Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "—", + "actor": "demo@example.com", "ip": "155.117.82.111", "outcome": "失败", + "detail": "\nAuthentication failure : You entered an incorrect user name or password."}, }, } diff --git a/internal/service/ociconfig.go b/internal/service/ociconfig.go index 1717faf..b866b21 100644 --- a/internal/service/ociconfig.go +++ b/internal/service/ociconfig.go @@ -16,11 +16,10 @@ import ( // OciConfigService 管理 API Key 配置的导入、测活与快照同步。 type OciConfigService struct { - db *gorm.DB - cipher *crypto.Cipher - client oci.Client - cleanupTasks *TaskService - cleanupEvents *LogEventService + db *gorm.DB + cipher *crypto.Cipher + client oci.Client + cleanupTasks *TaskService // auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟), // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 auditRaw *cache.Cache @@ -31,10 +30,9 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)} } -// SetTenantCleanupDeps 注入租户删除提交后的任务与告警内存状态同步依赖。 -func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService, events *LogEventService) { +// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。 +func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService) { s.cleanupTasks = tasks - s.cleanupEvents = events } // ImportInput 是导入一份 API Key 的输入: diff --git a/internal/service/tenantdelete.go b/internal/service/tenantdelete.go index 75f112a..1e3be20 100644 --- a/internal/service/tenantdelete.go +++ b/internal/service/tenantdelete.go @@ -16,7 +16,6 @@ import ( type tenantDeleteResult struct { config model.OciConfig deletedTaskIDs []uint - alertRuleIDs []uint channelsGone bool } @@ -48,7 +47,7 @@ func (s *OciConfigService) deleteTenantInTx(tx *gorm.DB, id uint, result *tenant if err := s.deleteTenantTasks(tx, id, result); err != nil { return err } - if err := deleteTenantEvents(tx, id, result); err != nil { + if err := deleteTenantEvents(tx, id); err != nil { return err } if err := deleteTenantAI(tx, id, result); err != nil { @@ -205,17 +204,9 @@ func resetTenantTask(tx *gorm.DB, action tenantTaskAction) error { return nil } -func deleteTenantEvents(tx *gorm.DB, id uint, result *tenantDeleteResult) error { - ruleIDs, affectedRules, err := loadTenantEventRefs(tx, id) - if err != nil { - return err - } - if err := deleteAlertHits(tx, id); err != nil { - return err - } - result.alertRuleIDs = mergeIDs(ruleIDs, affectedRules) - if err := deleteWhere(tx, &model.AlertRule{}, "oci_config_id = ?", id); err != nil { - return fmt.Errorf("delete tenant alert rules: %w", err) +func deleteTenantEvents(tx *gorm.DB, id uint) error { + if err := lockTenantEventRows(tx, id); err != nil { + return fmt.Errorf("load tenant log events: %w", err) } if err := deleteWhere(tx, &model.LogEvent{}, "oci_config_id = ?", id); err != nil { return fmt.Errorf("delete tenant log events: %w", err) @@ -223,84 +214,15 @@ func deleteTenantEvents(tx *gorm.DB, id uint, result *tenantDeleteResult) error return nil } -func loadTenantEventRefs(tx *gorm.DB, id uint) ([]uint, []uint, error) { - ruleIDs, err := lockedTenantRuleIDs(tx, id) - if err != nil { - return nil, nil, fmt.Errorf("load tenant alert rules: %w", err) - } - if err := lockTenantEventRows(tx, id); err != nil { - return nil, nil, fmt.Errorf("load tenant log events: %w", err) - } - affected, err := alertHitRuleIDs(tx, id) - if err != nil { - return nil, nil, err - } - return ruleIDs, affected, nil -} - -func lockedTenantRuleIDs(tx *gorm.DB, id uint) ([]uint, error) { - var rows []model.AlertRule - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). - Where("oci_config_id = ?", id).Order("id").Find(&rows).Error - ids := make([]uint, 0, len(rows)) - for _, row := range rows { - ids = append(ids, row.ID) - } - return ids, err -} - // lockTenantEventRows 对租户全部日志事件行加 FOR UPDATE 锁(SQLite 忽略, -// MySQL/PG 阻塞并发命中写入);事件可达数万,ID 不再回传拼接 SQL, -// 后续删除与关联查询一律用子查询,避免绑定变量上限。 +// MySQL/PG 阻塞并发写入);事件可达数万,ID 不回传拼接 SQL,删除用条件语句, +// 避免绑定变量上限。 func lockTenantEventRows(tx *gorm.DB, id uint) error { var rows []model.LogEvent return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). Where("oci_config_id = ?", id).Order("id").Find(&rows).Error } -// tenantEventIDs 构造「本租户日志事件 ID」子查询,供 IN (?) 内联。 -func tenantEventIDs(tx *gorm.DB, id uint) *gorm.DB { - return tx.Model(&model.LogEvent{}).Select("id").Where("oci_config_id = ?", id) -} - -// alertHitRuleIDs 找出命中引用了本租户事件的规则 ID(去重,可能含他租户/全局规则)。 -func alertHitRuleIDs(tx *gorm.DB, id uint) ([]uint, error) { - ids := make([]uint, 0) - err := tx.Model(&model.AlertRuleHit{}).Where("log_event_id IN (?)", tenantEventIDs(tx, id)). - Distinct().Pluck("rule_id", &ids).Error - if err != nil { - return nil, fmt.Errorf("load affected alert rules: %w", err) - } - return ids, nil -} - -func mergeIDs(groups ...[]uint) []uint { - seen := make(map[uint]struct{}) - out := make([]uint, 0) - for _, ids := range groups { - for _, id := range ids { - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - return out -} - -// deleteAlertHits 删除本租户相关的全部命中:规则属于本租户,或命中引用了 -// 本租户的日志事件(他租户/全局规则命中本租户事件的行一并清)。 -func deleteAlertHits(tx *gorm.DB, id uint) error { - rules := tx.Model(&model.AlertRule{}).Select("id").Where("oci_config_id = ?", id) - err := tx.Where("rule_id IN (?) OR log_event_id IN (?)", rules, tenantEventIDs(tx, id)). - Delete(&model.AlertRuleHit{}).Error - if err != nil { - return fmt.Errorf("delete tenant alert hits: %w", err) - } - return nil -} - func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error { channelIDs, err := lockedAiChannelIDs(tx, id) if err != nil { @@ -451,9 +373,6 @@ func (s *OciConfigService) afterTenantDelete(ctx context.Context, result *tenant if client, ok := s.client.(tenancyCacheInvalidator); ok { client.InvalidateTenancy(result.config.TenancyOCID) } - if s.cleanupEvents != nil { - s.cleanupEvents.ClearAlertCooldown(result.alertRuleIDs) - } if s.cleanupTasks != nil { s.cleanupTasks.ApplyTenantCleanup(ctx, result.deletedTaskIDs, result.channelsGone) } diff --git a/internal/service/tenantdelete_test.go b/internal/service/tenantdelete_test.go index 70faa58..d9735a7 100644 --- a/internal/service/tenantdelete_test.go +++ b/internal/service/tenantdelete_test.go @@ -55,7 +55,7 @@ func newTenantDeleteEnv(t *testing.T, client oci.Client) (*OciConfigService, *Ta } configs := NewOciConfigService(db, cipher, client) tasks := NewTaskService(db, configs, nil, nil) - configs.SetTenantCleanupDeps(tasks, nil) + configs.SetTenantCleanupDeps(tasks) return configs, tasks, db } @@ -64,7 +64,7 @@ func migrateTenantDeleteModels(t *testing.T, db *gorm.DB) { err := db.AutoMigrate( &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, - &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, + &model.LogEvent{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}, &model.AiContentLog{}, &model.Proxy{}, &model.AiKey{}, &model.SystemLog{}, ) @@ -162,19 +162,9 @@ func seedTenantEvents(t *testing.T, db *gorm.DB, target, other uint) { t.Helper() targetEvent := model.LogEvent{OciConfigID: target, MessageID: "target-event"} otherEvent := model.LogEvent{OciConfigID: other, MessageID: "other-event"} - targetRule := model.AlertRule{Name: "target-rule", OciConfigID: target} - globalRule := model.AlertRule{Name: "global-rule", OciConfigID: 0} - for _, value := range []any{&targetEvent, &otherEvent, &targetRule, &globalRule} { + for _, value := range []any{&targetEvent, &otherEvent} { mustCreate(t, db, value) } - hits := []model.AlertRuleHit{ - {RuleID: targetRule.ID, LogEventID: otherEvent.ID}, - {RuleID: globalRule.ID, LogEventID: targetEvent.ID}, - {RuleID: globalRule.ID, LogEventID: otherEvent.ID}, - } - for i := range hits { - mustCreate(t, db, &hits[i]) - } } func seedTenantAI(t *testing.T, db *gorm.DB, target, other uint) { @@ -202,7 +192,7 @@ func assertTenantRowsGone(t *testing.T, db *gorm.DB, id uint) { rows := []any{ &model.OciConfig{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.LogEvent{}, - &model.AlertRule{}, &model.AiChannel{}, + &model.AiChannel{}, } for _, value := range rows { column := "oci_config_id" @@ -212,7 +202,6 @@ func assertTenantRowsGone(t *testing.T, db *gorm.DB, id uint) { assertCount(t, db, value, column+" = ?", []any{id}, 0) } assertCount(t, db, &model.Setting{}, "key = ?", []any{secretKey(id)}, 0) - assertCount(t, db, &model.AlertRuleHit{}, "", nil, 1) assertCount(t, db, &model.AiModelCache{}, "", nil, 1) assertCount(t, db, &model.AiCallLog{}, "", nil, 1) assertCount(t, db, &model.AiContentLog{}, "", nil, 1) @@ -248,31 +237,10 @@ func assertRemainingIndirectRows(t *testing.T, db *gorm.DB, otherID uint) { t.Fatalf("load other AI call: %v", err) } assertCount(t, db, &model.AiContentLog{}, "call_log_id = ?", []any{call.ID}, 1) - assertRemainingAlertHit(t, db, otherID) -} - -func assertRemainingAlertHit(t *testing.T, db *gorm.DB, otherID uint) { - t.Helper() - var hit model.AlertRuleHit - if err := db.First(&hit).Error; err != nil { - t.Fatalf("load remaining alert hit: %v", err) - } - var rule model.AlertRule - var event model.LogEvent - if err := db.First(&rule, hit.RuleID).Error; err != nil { - t.Fatalf("load remaining rule: %v", err) - } - if err := db.First(&event, hit.LogEventID).Error; err != nil { - t.Fatalf("load remaining event: %v", err) - } - if rule.OciConfigID != 0 || event.OciConfigID != otherID { - t.Errorf("remaining hit = rule cfg %d/event cfg %d, want global/other", rule.OciConfigID, event.OciConfigID) - } } func assertRetainedGlobals(t *testing.T, db *gorm.DB) { t.Helper() - assertCount(t, db, &model.AlertRule{}, "oci_config_id = 0", nil, 1) assertCount(t, db, &model.Proxy{}, "", nil, 1) assertCount(t, db, &model.AiKey{}, "", nil, 1) assertCount(t, db, &model.SystemLog{}, "", nil, 1) @@ -629,35 +597,6 @@ func TestDeleteTenantSkipsCorruptTaskPayload(t *testing.T) { assertCount(t, db, &model.OciConfig{}, "id = ?", []any{target.ID}, 0) } -func TestDeleteTenantEventsAffectedRules(t *testing.T) { - _, _, db := newTenantDeleteEnv(t, &fakeClient{}) - target, other := seedDeleteTenants(t, db) - seedTenantEvents(t, db, target.ID, other.ID) - - // 规则 ID 须在删除前取:target-rule 会随租户一并删除 - var targetRule, globalRule model.AlertRule - db.Where("name = ?", "target-rule").First(&targetRule) - db.Where("name = ?", "global-rule").First(&globalRule) - - result := &tenantDeleteResult{} - err := db.Transaction(func(tx *gorm.DB) error { - return deleteTenantEvents(tx, target.ID, result) - }) - if err != nil { - t.Fatalf("deleteTenantEvents: %v", err) - } - // target-rule 属本租户,global-rule 的命中引用了本租户事件:都应回收冷却 - got := map[uint]bool{} - for _, id := range result.alertRuleIDs { - got[id] = true - } - if len(got) != 2 || !got[targetRule.ID] || !got[globalRule.ID] { - t.Errorf("alertRuleIDs = %v, want 含 target-rule(%d) 与 global-rule(%d)", result.alertRuleIDs, targetRule.ID, globalRule.ID) - } - // 仅保留 global-rule × other-event 一条命中 - assertCount(t, db, &model.AlertRuleHit{}, "", nil, 1) -} - func TestDeleteTenantManyEventsNoVarLimit(t *testing.T) { // 回归:事件数超 SQLite 绑定变量上限(32766)时删除仍成功(旧实现 IN 展开必失败) configs, _, db := newTenantDeleteEnv(t, &fakeClient{})