Compare commits
6
Commits
4e2bab3032
..
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e |
@@ -20,6 +20,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
||||
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
||||
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
||||
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
||||
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# OCI 审计事件集成约定
|
||||
|
||||
> 2026-07 审计日志重构(数据源切换 + 检索 + 配额回退)沉淀;实现见 `internal/oci/audit.go`。
|
||||
|
||||
## 数据源:双通道,Search 主路 + Audit API 回退
|
||||
|
||||
- **Audit API(`audit.ListEvents`)无排序参数,窗口内固定按处理时间正序分页**。任何"从最新往更早"的列表需求禁止直接用它凑批——首批会拿到窗口内最旧的一段(2026-07-16 曾以此形态上线出 bug)。
|
||||
- 倒序列表一律走 **Logging Search**(`loggingsearch.SearchLogs`,`search "<tenancy>/_Audit" | ... | sort by datetime desc`)。硬约束:单次查询时间窗 ≤ 14 天、limit ≤ 1000、时间过滤基于**处理时间**而非发生时间。
|
||||
- **部分免费租户 Logging Search 服务配额为零**(报错含 `Rate limit exceeded` + `maxQueriesPerMinute: 0`,SDK 解析该错误体还会失败),属永久不可用,须自动回退 Audit API(小窗正序 + 前端全局重排);普通限流(配额非零)不回退。游标携带通道模式,续查不再试错。
|
||||
|
||||
## 检索语义
|
||||
|
||||
- `logContent = '*词*'` 是对整条日志 JSON **所有字段值**的包含匹配,会命中隐藏认证元数据(如 `opc-principal` 头里的 `ttype: login`),只可作服务端粗筛;**用户可见语义必须再做客户端精筛**(只匹配列表可见字段,不区分大小写,`*` 通配分段)。
|
||||
- 用户输入进检索语句前必须消毒(去引号/反斜杠/控制字符、截断),见 `SanitizeAuditTerm`。
|
||||
|
||||
## 批式回溯的预算纪律
|
||||
|
||||
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
|
||||
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.7.1]
|
||||
|
||||
### Added
|
||||
|
||||
- 审计事件接口新增检索参数 `q`:服务端 `logContent` 全文粗筛 + 可见字段(事件名 / 资源 / 操作者 / IP / 请求路径等)精筛,不区分大小写、支持 `*` 通配;关键字内嵌续查游标,跨批过滤口径一致。全文粗筛不作最终判定——`logContent` 会命中隐藏认证元数据(如 `opc-principal` 头里的 `ttype: login`)
|
||||
- 审计批式响应新增 `scannedThrough`(已完整回溯到的时刻),供前端展示回溯进度
|
||||
- Logging Search 服务配额为零的租户(报错含 `maxQueriesPerMinute: 0`,部分免费租户如此)自动回退 Audit API 小窗回溯:游标携带通道模式、续查不再试错,搜索降级为客户端可见字段匹配,审计页不再报错
|
||||
|
||||
### Changed
|
||||
|
||||
- 审计事件数据源由 Audit API 切换为 Logging Search(`_Audit` 日志按 `datetime` 倒序,单页 200 条);空窗倍增上限由 30 天收紧到 14 天(单次查询时间窗硬限),单批新增约 20 秒时间预算,命中稀疏的深回溯拆成多个有界请求由前端接力
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复审计日志固定显示旧事件、刷新也看不到最新记录:Audit API 无排序参数且窗口内固定按处理时间正序,原实现凑满一批即返回,首批永远是 24h 窗口内最旧的一段,「向更早加载」实际在向更新方向翻页
|
||||
|
||||
## [0.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- AI 网关运行时设置扩展(`GET` / `PUT /api/v1/ai-settings`,持久化、即时生效):
|
||||
- Responses 流式保险丝:开关 + 阈值(KB,1..1024),超阈值的流式请求预防性改非流式上游并合成最小 SSE
|
||||
- grok 服务端搜索工具默认注入:`xai.` 前缀模型的 Responses 请求按开关默认注入 `web_search` / `x_search`,请求 `tools` 已含同名工具(任意参数形态)时不覆盖,注入动作记服务端日志
|
||||
- 聚合模型目录端点 `GET /api/v1/ai-model-catalog`:启用渠道去重、含能力字段(空能力归一 CHAT),与模型列表同口径(随「过滤弃用」开关),供设置页黑名单添加弹窗使用
|
||||
|
||||
### Changed
|
||||
|
||||
- Responses 流式保险丝触发口径由「完整请求体 > 76KB(环境变量 `AI_RESP_STREAM_UPGRADE_KB`)」改为「`instructions` + `tools` 原始字节合计超阈值(默认开、60KB,设置页可调)」:复测证实约 82KB 的纯体积断流已由上游修复,而 `instructions`+`tools` 合计 >≈64.5KB 的流式静默断流仍存在(`input` 不计入);环境变量随之废弃
|
||||
- 升级 oci-go-sdk 到 v65.121.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复多网卡实例的列表 / 详情地址字段随机:并发填充改以主网卡为准(主卡未返回前允许先到网卡兜底,主卡到达后覆盖并锁定),公网 IP / 私网 IP / IPv6 / 子网恒为主网卡数据
|
||||
|
||||
## [0.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.6.0
|
||||
v0.7.1
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/wangdefaa/oci-portal/releases/latest)
|
||||
[](go.mod)
|
||||
[](docker-compose.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||
|
||||
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,27 +23,36 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
||||
|
||||
## 界面预览
|
||||
|
||||
| 总览 | 登录 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-overview.png" width="960" alt="OCI Portal 总览">
|
||||
</p>
|
||||
|
||||
| 租户 | 任务 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
<details>
|
||||
<summary><strong>展开更多界面截图</strong></summary>
|
||||
|
||||
| AI 网关 | 通知设置 |
|
||||
| 登录 | 租户 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| 任务 | AI 网关 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 通知设置 |
|
||||
| --- |
|
||||
|  |
|
||||
|
||||
</details>
|
||||
|
||||
## 核心能力
|
||||
|
||||
- **租户与区域**:集中管理多份 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 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
|
||||
| 能力域 | 覆盖范围 |
|
||||
| --- | --- |
|
||||
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 |
|
||||
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
||||
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
||||
|
||||
## 运行形态
|
||||
|
||||
@@ -57,7 +68,9 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。
|
||||
> [!NOTE]
|
||||
> 默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,
|
||||
> 不应据此推断服务支持多副本并发运行。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -65,6 +78,10 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
||||
|
||||
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||
|
||||
> [!CAUTION]
|
||||
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
|
||||
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
|
||||
|
||||
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||
|
||||
```bash
|
||||
@@ -79,19 +96,27 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
2. 准备数据目录并启动:
|
||||
2. 准备数据目录:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
```
|
||||
|
||||
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。
|
||||
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
|
||||
Docker Desktop 用户通常不需要执行:
|
||||
|
||||
```bash
|
||||
sudo chown 65532:65532 data
|
||||
```
|
||||
|
||||
3. 启动并检查状态:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
3. 查看初始管理员密码并登录:
|
||||
4. 查看初始管理员密码并登录:
|
||||
|
||||
```bash
|
||||
grep '^ADMIN_PASSWORD=' .env
|
||||
@@ -99,7 +124,14 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
||||
|
||||
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
||||
|
||||
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。
|
||||
启动异常时查看最近日志:
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=100 oci-portal
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
|
||||
|
||||
### 二进制运行
|
||||
|
||||
@@ -107,6 +139,8 @@ Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64
|
||||
|
||||
```bash
|
||||
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
||||
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/SHA256SUMS
|
||||
grep 'oci-portal-server-linux-amd64$' SHA256SUMS | sha256sum -c -
|
||||
chmod +x oci-portal-server-linux-amd64
|
||||
|
||||
# 复用上文生成并妥善保存的 .env。
|
||||
@@ -142,7 +176,9 @@ CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
|
||||
|
||||
## 生产部署
|
||||
|
||||
服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。
|
||||
> [!WARNING]
|
||||
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
|
||||
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
|
||||
|
||||
### Caddy
|
||||
|
||||
@@ -229,29 +265,31 @@ OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`do
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
| 变量 | 使用条件 | 默认值 | 说明 |
|
||||
| --- | :---: | --- | --- |
|
||||
| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||
| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
||||
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
|
||||
| `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` |
|
||||
| `DB_DSN` | MySQL / PostgreSQL | — | 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 关闭并限制管理面访问来源
|
||||
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
|
||||
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
|
||||
`data/oci-portal.db`,避免在线复制产生不一致快照。
|
||||
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
|
||||
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
|
||||
会自动完成数据库迁移。
|
||||
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
+116
-56
@@ -1,23 +1,33 @@
|
||||
<a id="top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||
|
||||
# AI 网关
|
||||
|
||||
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
|
||||
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
|
||||
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
|
||||
>
|
||||
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
|
||||
> 为准;协议差异、兼容改写和实测边界以本文为准。
|
||||
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
|
||||
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
|
||||
> 兼容改写和实测边界以本文为准。
|
||||
|
||||
**兼容快照:2026-07-15**
|
||||
|
||||
## 快速导航
|
||||
|
||||
- [快速接入](#quick-start)
|
||||
- [端点一览](#endpoints)
|
||||
- [路由与全局行为](#routing)
|
||||
- [Codex 接入](#codex)
|
||||
- [已知限制](#limitations)
|
||||
- [字段兼容矩阵](#compatibility)
|
||||
- [实现索引](#implementation)
|
||||
| 文档属性 | 当前值 |
|
||||
| --- | --- |
|
||||
| 兼容快照 | **2026-07-16** |
|
||||
| API 基址 | `/ai/v1` |
|
||||
| 首选对话协议 | OpenAI Responses |
|
||||
| 会话模式 | 无状态,客户端携带完整上下文 |
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
@@ -25,19 +35,13 @@
|
||||
|
||||
### 基础地址与鉴权
|
||||
|
||||
```text
|
||||
Base URL: https://<网关地址>/ai/v1
|
||||
```
|
||||
网关密钥在管理面板中创建。连接信息如下:
|
||||
|
||||
网关密钥在管理面板中创建,支持以下任一请求头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
```http
|
||||
x-api-key: sk-...
|
||||
```
|
||||
| 项目 | 配置 |
|
||||
| --- | --- |
|
||||
| Base URL | `https://<网关地址>/ai/v1` |
|
||||
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
|
||||
| API Key 鉴权 | `x-api-key: sk-...` |
|
||||
|
||||
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||
@@ -50,6 +54,15 @@ curl "https://<网关地址>/ai/v1/models" \
|
||||
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
||||
```
|
||||
|
||||
再发起一条最小 Responses 请求;请将示例模型替换为模型列表中的可见模型:
|
||||
|
||||
```bash
|
||||
curl "https://<网关地址>/ai/v1/responses" \
|
||||
-H "Authorization: Bearer $OCI_PORTAL_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"xai.grok-4.3","input":"你好,请用一句话介绍自己。"}'
|
||||
```
|
||||
|
||||
<a id="endpoints"></a>
|
||||
|
||||
## 端点一览
|
||||
@@ -66,12 +79,14 @@ curl "https://<网关地址>/ai/v1/models" \
|
||||
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||
|
||||
选择建议:
|
||||
### 如何选择协议
|
||||
|
||||
- 新客户端优先使用 **Responses**
|
||||
- Anthropic SDK 或 Claude 生态客户端使用 **Messages**
|
||||
- 仅支持旧 OpenAI 对话协议的客户端使用 **Chat Completions**
|
||||
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
|
||||
| 使用场景 | 推荐接口 |
|
||||
| --- | --- |
|
||||
| 新客户端、推理模型、服务端工具 | **Responses** |
|
||||
| Anthropic SDK、Claude 生态客户端 | **Messages** |
|
||||
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
|
||||
| 向量、语音、重排与安全审核 | 对应专用端点 |
|
||||
|
||||
<a id="routing"></a>
|
||||
|
||||
@@ -106,6 +121,14 @@ flowchart LR
|
||||
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
||||
|
||||
### 可配置运行策略
|
||||
|
||||
| 策略 | 默认行为 | 配置入口 |
|
||||
| --- | --- | --- |
|
||||
| Responses 流式保险丝 | 开启;阈值 `60 KB`,按 `instructions` 与 `tools` 两个字段的原始 JSON 值计算 | **设置 → AI → 流式保险丝** |
|
||||
| Grok 服务端搜索 | 对 `xai.` 模型默认注入 `web_search` 与 `x_search`;同名工具不重复覆盖 | **设置 → AI → Grok 服务端搜索工具** |
|
||||
| 弃用模型过滤 | 开启后从模型列表、请求路由与探测候选中统一排除 | **设置 → AI → 模型治理** |
|
||||
|
||||
<a id="codex"></a>
|
||||
|
||||
## Codex 接入
|
||||
@@ -143,7 +166,7 @@ model = "xai.grok-4.3"
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `codex-cli 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||
@@ -165,22 +188,35 @@ Codex 工具兼容现状:
|
||||
|
||||
## 已知限制
|
||||
|
||||
### 超大流式请求
|
||||
### `instructions` / `tools` 大体量流式断流
|
||||
|
||||
> [!WARNING]
|
||||
> 2026-07 实测:OCI xAI 兼容面对**完整请求体超过约 82 KB** 的流式请求,
|
||||
> 可能在推理阶段提前断流;该现象由请求体积触发,与工具构成或字符集无关,
|
||||
> 同一请求改为非流式可正常完成。
|
||||
> `instructions` 与 `tools` 两个字段的原始 JSON 值合计超过约 **64.5 KB** 时,
|
||||
> 上游流式请求可能在推理阶段静默断连:连接直接 EOF,不发送 `error` 或终态事件。
|
||||
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
||||
|
||||
网关按协议采取不同保护:
|
||||
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
||||
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的
|
||||
`KB` 均按 `1024 B` 计算。
|
||||
|
||||
- **Responses**:兼容改写后的请求体超过 **76 KB** 时,预防性改为非流式上游
|
||||
请求,再合成最小 SSE 序列(`response.created` →
|
||||
`response.output_item.done` → `response.completed`);结果语义保留,但没有增量输出
|
||||
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
|
||||
非流式重做,并合成对应 chunk / event 序列
|
||||
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
|
||||
部分事件
|
||||
| 协议 | 网关保护 | 客户端表现 |
|
||||
| --- | --- | --- |
|
||||
| Responses | 保险丝默认开启;超过 `60 KB` 时改走非流式上游,并合成最小 SSE 序列 | 结果语义保留,但不再增量输出 |
|
||||
| Chat Completions / Messages | 客户端尚未收到内容就断流时,自动改用非流式重做 | 合成对应 chunk / event 序列 |
|
||||
| 已开始输出的流 | 无法透明重试;调用日志记录提前终止 | 客户端可能只收到部分事件 |
|
||||
|
||||
Responses 合成的最小事件序列为:`response.created` →
|
||||
`response.output_item.done` → `response.completed`。保险丝可在
|
||||
**设置 → AI → 流式保险丝** 调整或关闭。
|
||||
|
||||
<details>
|
||||
<summary><strong>历史问题:完整请求体体积断流(已由上游修复)</strong></summary>
|
||||
|
||||
2026-07-15 曾在完整请求体约 `82 KB`(含 `input`)时观测到纯体积流式断流。
|
||||
2026-07-16 复核 `83 KB`、真实 Codex 形态 `104.5 KB`、`200 KB` 与 `400 KB`
|
||||
请求均正常完成;Chicago 与 Phoenix、签名与 API Key 两条路径结果一致。
|
||||
|
||||
</details>
|
||||
|
||||
### ZDR 与文件输入
|
||||
|
||||
@@ -192,16 +228,20 @@ File content is currently unsupported for ZDR customers
|
||||
|
||||
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||
|
||||
### 规格与实测边界
|
||||
|
||||
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
|
||||
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
||||
|
||||
<a id="compatibility"></a>
|
||||
|
||||
## 字段兼容矩阵
|
||||
|
||||
矩阵基线:
|
||||
> [!IMPORTANT]
|
||||
> 本项目提供兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。部分 OCI
|
||||
> OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
||||
|
||||
### 矩阵索引
|
||||
|
||||
[Responses](#compat-responses) · [Chat Completions](#compat-chat) · [Messages](#compat-messages) · [Embeddings](#compat-embeddings) · [语音生成](#compat-audio) · [Rerank](#compat-rerank) · [Moderations](#compat-moderations) · [Models](#compat-models)
|
||||
|
||||
<details>
|
||||
<summary><strong>展开参考规格</strong></summary>
|
||||
|
||||
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
||||
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||
@@ -211,6 +251,8 @@ File content is currently unsupported for ZDR customers
|
||||
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||
|
||||
</details>
|
||||
|
||||
| 标记 | 含义 |
|
||||
| :---: | --- |
|
||||
| ✅ | 网关直接支持 |
|
||||
@@ -218,6 +260,8 @@ File content is currently unsupported for ZDR customers
|
||||
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||
|
||||
<a id="compat-responses"></a>
|
||||
|
||||
### OpenAI Responses
|
||||
|
||||
**`POST /ai/v1/responses` · 无状态主接口**
|
||||
@@ -239,12 +283,12 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||
| `stream` | ◐ | 支持 SSE;请求体超过 76 KB 时改为非流式上游并合成 SSE |
|
||||
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<details>
|
||||
<summary><strong>工具兼容</strong></summary>
|
||||
|
||||
| 工具或参数 | 状态 | 网关行为 |
|
||||
@@ -254,7 +298,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||
| `custom:apply_patch` | ◐ | 整体剥离;grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||
| `custom:apply_patch` | ◐ | 整体剥离;Grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||
@@ -277,6 +321,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
- 未知模型返回 404,无可用渠道返回 503
|
||||
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||
|
||||
<a id="compat-chat"></a>
|
||||
|
||||
### OpenAI Chat Completions
|
||||
|
||||
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||
@@ -332,6 +378,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
`prompt_tokens_details.cached_tokens`
|
||||
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||
|
||||
<a id="compat-messages"></a>
|
||||
|
||||
### Anthropic Messages
|
||||
|
||||
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||
@@ -385,6 +433,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||
|
||||
<a id="compat-embeddings"></a>
|
||||
|
||||
### OpenAI Embeddings
|
||||
|
||||
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||
@@ -402,6 +452,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
|
||||
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||
|
||||
<a id="compat-audio"></a>
|
||||
|
||||
### 语音生成
|
||||
|
||||
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||
@@ -442,6 +494,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||
- 无 token 用量口径,调用日志只记时延与渠道
|
||||
|
||||
<a id="compat-rerank"></a>
|
||||
|
||||
### Rerank
|
||||
|
||||
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||
@@ -459,6 +513,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||
|
||||
<a id="compat-moderations"></a>
|
||||
|
||||
### Moderations
|
||||
|
||||
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||
@@ -480,6 +536,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||
- 响应 `model` 恒为 `oci-guardrails`
|
||||
|
||||
<a id="compat-models"></a>
|
||||
|
||||
### Models
|
||||
|
||||
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||
@@ -496,7 +554,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
|
||||
<a id="implementation"></a>
|
||||
|
||||
## 实现索引
|
||||
## 附录:实现索引
|
||||
|
||||
| 端点 / 能力 | 主要实现 |
|
||||
| --- | --- |
|
||||
@@ -510,3 +568,5 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
||||
|
||||
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||
|
||||
[返回顶部](#top)
|
||||
|
||||
+77
-1
@@ -786,6 +786,27 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-model-catalog": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "聚合模型目录",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-models": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -839,7 +860,7 @@ const docTemplate = `{
|
||||
"summary": "更新 AI 网关全局设置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"description": "全量提交;保险丝阈值限 1..1024 KB",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -854,6 +875,15 @@ const docTemplate = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1542,6 +1572,12 @@ const docTemplate = `{
|
||||
"description": "单批目标条数,缺省 100,上限 200",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -6050,7 +6086,22 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"type": "boolean"
|
||||
},
|
||||
"grokWebSearch": {
|
||||
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||
"type": "boolean"
|
||||
},
|
||||
"grokXSearch": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"streamGuardEnabled": {
|
||||
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||
"type": "boolean"
|
||||
},
|
||||
"streamGuardKB": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6621,6 +6672,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9358,6 +9420,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_service.AggregatedModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"capability": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_service.AuditEventsView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9372,6 +9445,9 @@ const docTemplate = `{
|
||||
"items": {
|
||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||
}
|
||||
},
|
||||
"scannedThrough": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+77
-1
@@ -779,6 +779,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-model-catalog": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "聚合模型目录",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-models": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -832,7 +853,7 @@
|
||||
"summary": "更新 AI 网关全局设置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"description": "全量提交;保险丝阈值限 1..1024 KB",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -847,6 +868,15 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1535,6 +1565,12 @@
|
||||
"description": "单批目标条数,缺省 100,上限 200",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -6043,7 +6079,22 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"type": "boolean"
|
||||
},
|
||||
"grokWebSearch": {
|
||||
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||
"type": "boolean"
|
||||
},
|
||||
"grokXSearch": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"streamGuardEnabled": {
|
||||
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||
"type": "boolean"
|
||||
},
|
||||
"streamGuardKB": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6614,6 +6665,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9351,6 +9413,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_service.AggregatedModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"capability": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_service.AuditEventsView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9365,6 +9438,9 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||
}
|
||||
},
|
||||
"scannedThrough": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+54
-1
@@ -49,7 +49,22 @@ definitions:
|
||||
internal_api.aiSettingsResponse:
|
||||
properties:
|
||||
filterDeprecated:
|
||||
description: FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||
type: boolean
|
||||
grokWebSearch:
|
||||
description: |-
|
||||
GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||
请求 tools 已包含同名工具时不覆盖
|
||||
type: boolean
|
||||
grokXSearch:
|
||||
type: boolean
|
||||
streamGuardEnabled:
|
||||
description: |-
|
||||
StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||
instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||
type: boolean
|
||||
streamGuardKB:
|
||||
type: integer
|
||||
type: object
|
||||
internal_api.attachBootVolumeRequest:
|
||||
properties:
|
||||
@@ -427,6 +442,13 @@ definitions:
|
||||
$ref: '#/definitions/oci-portal_internal_model.UserIdentity'
|
||||
type: array
|
||||
type: object
|
||||
internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/oci-portal_internal_service.AggregatedModel'
|
||||
type: array
|
||||
type: object
|
||||
internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView:
|
||||
properties:
|
||||
items:
|
||||
@@ -2232,6 +2254,13 @@ definitions:
|
||||
description: 卷本身的名称(attachment 名是自动生成的)
|
||||
type: string
|
||||
type: object
|
||||
oci-portal_internal_service.AggregatedModel:
|
||||
properties:
|
||||
capability:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
oci-portal_internal_service.AuditEventsView:
|
||||
properties:
|
||||
cursor:
|
||||
@@ -2242,6 +2271,8 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/oci-portal_internal_oci.AuditEvent'
|
||||
type: array
|
||||
scannedThrough:
|
||||
type: string
|
||||
type: object
|
||||
oci-portal_internal_service.Changes:
|
||||
additionalProperties:
|
||||
@@ -3135,6 +3166,18 @@ paths:
|
||||
summary: AI 调用日志列表
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-model-catalog:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 聚合模型目录
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-models:
|
||||
get:
|
||||
responses:
|
||||
@@ -3161,7 +3204,7 @@ paths:
|
||||
- AI 管理
|
||||
put:
|
||||
parameters:
|
||||
- description: 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||
- description: 全量提交;保险丝阈值限 1..1024 KB
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -3172,6 +3215,12 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新 AI 网关全局设置
|
||||
@@ -3590,6 +3639,10 @@ paths:
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: 检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)
|
||||
in: query
|
||||
name: q
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
|
||||
+57
-5
@@ -422,9 +422,47 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// modelCatalog 返回启用渠道聚合去重后的模型目录(含能力),黑名单添加弹窗用。
|
||||
//
|
||||
// @Summary 聚合模型目录
|
||||
// @Tags AI 管理
|
||||
// @Success 200 {object} itemsResponse[service.AggregatedModel]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-model-catalog [get]
|
||||
func (h *aiAdminHandler) modelCatalog(c *gin.Context) {
|
||||
items, err := h.gw.AggregatedModels(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||
type aiSettingsResponse struct {
|
||||
// FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||
FilterDeprecated bool `json:"filterDeprecated"`
|
||||
// StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||
// instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||
StreamGuardEnabled bool `json:"streamGuardEnabled"`
|
||||
StreamGuardKB int `json:"streamGuardKB"`
|
||||
// GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||
// 请求 tools 已包含同名工具时不覆盖
|
||||
GrokWebSearch bool `json:"grokWebSearch"`
|
||||
GrokXSearch bool `json:"grokXSearch"`
|
||||
}
|
||||
|
||||
// currentAiSettings 汇总网关运行时设置为响应体。
|
||||
func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse {
|
||||
guardOn, guardKB := h.gw.StreamGuard()
|
||||
web, x := h.gw.GrokSearch()
|
||||
return aiSettingsResponse{
|
||||
FilterDeprecated: h.gw.FilterDeprecated(),
|
||||
StreamGuardEnabled: guardOn,
|
||||
StreamGuardKB: guardKB,
|
||||
GrokWebSearch: web,
|
||||
GrokXSearch: x,
|
||||
}
|
||||
}
|
||||
|
||||
// aiSettings 返回 AI 网关全局设置。
|
||||
@@ -435,15 +473,16 @@ type aiSettingsResponse struct {
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [get]
|
||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||
}
|
||||
|
||||
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
||||
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
|
||||
//
|
||||
// @Summary 更新 AI 网关全局设置
|
||||
// @Tags AI 管理
|
||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
||||
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB"
|
||||
// @Success 200 {object} aiSettingsResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [put]
|
||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||
@@ -452,9 +491,22 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
||||
if req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
if err := h.gw.SetStreamGuard(ctx, req.StreamGuardEnabled, req.StreamGuardKB); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.gw.SetGrokSearch(ctx, req.GrokWebSearch, req.GrokXSearch); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||
}
|
||||
|
||||
@@ -587,8 +587,13 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
||||
return
|
||||
}
|
||||
logRespCompat(req.Model, compat)
|
||||
web, x := h.gw.GrokSearch()
|
||||
if injectedBody, injected := service.RespInjectGrokTools(body, req.Model, web, x); len(injected) > 0 {
|
||||
body = injectedBody
|
||||
log.Printf("responses 直通(model=%s): 默认注入 %s", req.Model, strings.Join(injected, ", "))
|
||||
}
|
||||
if req.Stream {
|
||||
if len(body) > service.RespStreamUpgradeLimit {
|
||||
if on, kb := h.gw.StreamGuard(); on && service.RespGuardBytes(body) > kb*1024 {
|
||||
h.responsesStreamUpgrade(c, body, req, compat)
|
||||
return
|
||||
}
|
||||
@@ -630,9 +635,9 @@ func logRespCompat(model string, compat service.RespCompat) {
|
||||
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// responsesStreamUpgrade 流式升级回退:上游对超大流式请求会在推理途中掐断
|
||||
// (实测 >~82KB,见 RespStreamUpgradeLimit),超限时改调非流式上游拿完整响应,
|
||||
// 本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||
// responsesStreamUpgrade 流式升级回退:instructions+tools 合计超过保险丝阈值
|
||||
// (设置页 AI Tab 配置,默认开 60KB;上游对 >≈64.5KB 会静默断流)时改调非流式
|
||||
// 上游拿完整响应,本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||
start := time.Now()
|
||||
nsBody, err := service.RespDisableStream(body)
|
||||
@@ -640,7 +645,8 @@ func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, r
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("responses 直通(model=%s): 请求体 %dKB 超流式安全上限,改走非流式合成 SSE", req.Model, len(body)/1024)
|
||||
log.Printf("responses 直通(model=%s): instructions+tools %dKB 超保险丝阈值,改走非流式合成 SSE",
|
||||
req.Model, service.RespGuardBytes(body)/1024)
|
||||
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||
if err != nil {
|
||||
|
||||
@@ -39,6 +39,7 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
||||
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-model-catalog", aiadmin.modelCatalog)
|
||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||
|
||||
@@ -68,13 +68,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||
// ---- 租户审计日志 ----
|
||||
|
||||
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。
|
||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
|
||||
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
|
||||
//
|
||||
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||
// @Success 200 {object} service.AuditEventsView
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||
@@ -84,7 +86,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
|
||||
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit, Q: c.Query("q")}
|
||||
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
+471
-186
@@ -6,19 +6,29 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||
)
|
||||
|
||||
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
||||
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
||||
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
||||
// maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条,
|
||||
// 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查。
|
||||
const maxAuditPages = 10
|
||||
|
||||
// auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时
|
||||
// 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求,
|
||||
// 前端按已回溯位置展示进度并自动续查。
|
||||
const auditBatchTimeBudget = 20 * time.Second
|
||||
|
||||
// auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询
|
||||
// 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。
|
||||
const auditSearchPageLimit = 200
|
||||
|
||||
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
||||
// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存,
|
||||
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||
type AuditEvent struct {
|
||||
EventId string `json:"eventId"`
|
||||
@@ -35,7 +45,7 @@ type AuditEvent struct {
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
||||
// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断,
|
||||
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||
type AuditEventsResult struct {
|
||||
Items []AuditEvent `json:"items"`
|
||||
@@ -43,6 +53,324 @@ type AuditEventsResult struct {
|
||||
NextPage string `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
// auditSearchClient 构造区域化的日志搜索客户端。审计数据源为 Logging Search
|
||||
// 的 _Audit 日志:Audit API 无排序参数、窗口内固定按处理时间正序,首批只能
|
||||
// 拿到窗口内最旧的一段;Logging Search 支持 datetime 倒序,才能从最新回溯。
|
||||
func (c *RealClient) auditSearchClient(cred Credentials, region string) (loggingsearch.LogSearchClient, error) {
|
||||
sc, err := loggingsearch.NewLogSearchClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return sc, fmt.Errorf("new logging search client: %w", err)
|
||||
}
|
||||
applyProxy(&sc.BaseClient, cred)
|
||||
if region != "" {
|
||||
sc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// auditSearchQuery 组装租户根 compartment 审计日志的倒序检索语句;
|
||||
// SummarizeMetricsData 遥测噪声占比高,服务端先滤一道减少无效翻页。
|
||||
// q 非空时追加 logContent 全文包含匹配——它扫的是整条 JSON 的所有值,只当
|
||||
// 粗筛;可见字段的精筛由 filterAuditTerm 兜底,避免隐藏元数据误命中。
|
||||
func auditSearchQuery(tenancyOCID, q string) string {
|
||||
query := fmt.Sprintf("search %q | where data.eventName != 'SummarizeMetricsData'", tenancyOCID+"/_Audit")
|
||||
if term := SanitizeAuditTerm(q); term != "" {
|
||||
query += fmt.Sprintf(" and logContent = '*%s*'", term)
|
||||
}
|
||||
return query + " | sort by datetime desc"
|
||||
}
|
||||
|
||||
// auditTermMaxLen 限制检索关键字长度,防止游标与查询语句被撑爆。
|
||||
const auditTermMaxLen = 100
|
||||
|
||||
// SanitizeAuditTerm 归一检索关键字:去除引号/反斜杠/控制字符防语句注入
|
||||
// (查询目标已锁定本租户 _Audit 流,注入最坏只是语法错),截断超长输入;
|
||||
// 保留 * 供用户通配。返回空串表示不追加过滤子句。
|
||||
// service 构造首查游标与本包组装语句共用,对篡改游标二次消毒兜底。
|
||||
func SanitizeAuditTerm(q string) string {
|
||||
out := make([]rune, 0, len(q))
|
||||
for _, r := range q {
|
||||
if r == '\'' || r == '"' || r == '\\' || r < 0x20 {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
if len(out) >= auditTermMaxLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||
// Search 配额为零的租户自动回退 Audit API 重查同一窗口(page 跨通道失效,重头吃窗)。
|
||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||
f := c.newAuditFetchers(cred, region)
|
||||
res, err := listAuditWindow(ctx, f.search, AuditCursor{Start: start, End: end, Page: page})
|
||||
if err != nil && isSearchQuotaZero(err) {
|
||||
res, err = listAuditWindow(ctx, f.audit, AuditCursor{Start: start, End: end})
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// listAuditWindow 用给定取页函数吃一个固定时间窗,最多 maxAuditPages 页;
|
||||
// 页预算耗尽即截断,NextPage 携带未消费的窗内游标。
|
||||
func listAuditWindow(ctx context.Context, fetch auditPageFetch, cur AuditCursor) (AuditEventsResult, error) {
|
||||
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||
for i := 0; i < maxAuditPages; i++ {
|
||||
items, next, err := fetch(ctx, cur)
|
||||
if err != nil {
|
||||
return AuditEventsResult{}, err
|
||||
}
|
||||
result.Items = appendKeptAuditEvents(result.Items, items)
|
||||
if next == "" {
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
cur.Page = next
|
||||
}
|
||||
result.Truncated = true
|
||||
result.NextPage = cur.Page
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// auditPageFetch 拉取游标位置的一页已映射事件,返回窗内下一页游标。
|
||||
type auditPageFetch func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error)
|
||||
|
||||
// auditFetchers 汇集两条数据通道:search 为 Logging Search 倒序主路,
|
||||
// audit 为 Search 配额为零租户的 Audit API 回退路。
|
||||
type auditFetchers struct {
|
||||
search auditPageFetch
|
||||
audit auditPageFetch
|
||||
}
|
||||
|
||||
// newAuditFetchers 构造两条通道的取页闭包;客户端惰性初始化,
|
||||
// 各模式的续查不会白建用不到的客户端。
|
||||
func (c *RealClient) newAuditFetchers(cred Credentials, region string) auditFetchers {
|
||||
return auditFetchers{search: c.searchFetcher(cred, region), audit: c.auditAPIFetcher(cred, region)}
|
||||
}
|
||||
|
||||
// searchFetcher 构造 Logging Search 通道的取页闭包。
|
||||
func (c *RealClient) searchFetcher(cred Credentials, region string) auditPageFetch {
|
||||
var sc *loggingsearch.LogSearchClient
|
||||
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
if sc == nil {
|
||||
cli, err := c.auditSearchClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sc = &cli
|
||||
}
|
||||
return searchAuditPage(ctx, *sc, cred.TenancyOCID, cur)
|
||||
}
|
||||
}
|
||||
|
||||
// auditAPIFetcher 构造 Audit API 回退通道的取页闭包。
|
||||
func (c *RealClient) auditAPIFetcher(cred Credentials, region string) auditPageFetch {
|
||||
var ac *audit.AuditClient
|
||||
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
if ac == nil {
|
||||
cli, err := c.auditClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ac = &cli
|
||||
}
|
||||
return listAuditPage(ctx, *ac, cred.TenancyOCID, cur)
|
||||
}
|
||||
}
|
||||
|
||||
// isSearchQuotaZero 识别「租户 Logging Search 配额为零」的失败:此类租户该
|
||||
// 服务永久不可用(maxQueriesPerMinute/maxConcurrentQueries 均为 0),应回退
|
||||
// Audit API;普通限流(配额非零)不回退,避免数据通道来回切换。
|
||||
func isSearchQuotaZero(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.ReplaceAll(err.Error(), " ", ""))
|
||||
return strings.Contains(msg, "ratelimitexceeded") && strings.Contains(msg, "maxqueriesperminute:0,")
|
||||
}
|
||||
|
||||
// appendKeptAuditEvents 过滤噪声后追加一页已映射事件;窗口式与批式查询共用。
|
||||
func appendKeptAuditEvents(dst []AuditEvent, items []AuditEvent) []AuditEvent {
|
||||
for _, ev := range items {
|
||||
if keepAuditEvent(ev) {
|
||||
dst = append(dst, ev)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
||||
|
||||
// 批式查询参数:单批翻页预算沿用 maxAuditPages;首窗 24h,连续空窗倍增
|
||||
// 加速跨越闲置期,上限 14 天(Logging Search 单次查询时间窗硬限);
|
||||
// 回溯下限为审计事件保留期 365 天。
|
||||
const (
|
||||
auditWindowHours = 24
|
||||
auditWindowMaxHours = 336
|
||||
auditRetentionDays = 365
|
||||
)
|
||||
|
||||
// auditModeFallback 标记游标处于 Audit API 回退模式:部分租户的
|
||||
// Logging Search 服务配额为零(maxQueriesPerMinute: 0),永久不可用。
|
||||
const auditModeFallback = "a"
|
||||
|
||||
// auditFallbackWindowHours 是回退模式的基准窗宽:Audit API 窗口内固定按
|
||||
// 处理时间正序且无排序参数,只能小窗回溯 + 前端全局重排保住从新到旧的体验。
|
||||
const auditFallbackWindowHours = 1
|
||||
|
||||
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内翻页游标、当前窗宽
|
||||
// (小时,空窗倍增的记忆)、检索关键字(随游标续查,保证跨批过滤一致)
|
||||
// 与数据通道模式(空为 Search 主路,"a" 为 Audit API 回退,续查沿用不再试错)。
|
||||
// 序列化为不透明 cursor 由 service 层负责。
|
||||
type AuditCursor struct {
|
||||
Start time.Time `json:"s"`
|
||||
End time.Time `json:"e"`
|
||||
Page string `json:"p,omitempty"`
|
||||
WindowHours int `json:"w"`
|
||||
Q string `json:"q,omitempty"`
|
||||
M string `json:"m,omitempty"`
|
||||
}
|
||||
|
||||
// toFallback 把游标切到 Audit API 回退模式:Search 页游标跨通道失效须清空;
|
||||
// 首窗收窄到基准窗宽,避免大窗正序分页又回到「首批全是窗口内最旧事件」的老问题。
|
||||
func (cur AuditCursor) toFallback() AuditCursor {
|
||||
cur.M = auditModeFallback
|
||||
cur.Page = ""
|
||||
cur.WindowHours = auditFallbackWindowHours
|
||||
if cur.End.Sub(cur.Start) > auditFallbackWindowHours*time.Hour {
|
||||
cur.Start = cur.End.Add(-auditFallbackWindowHours * time.Hour)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
||||
func NewAuditCursor(now time.Time) AuditCursor {
|
||||
end := now.UTC().Truncate(time.Minute)
|
||||
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
||||
}
|
||||
|
||||
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
||||
// 否则重置为该模式基准窗宽。done 为 true 表示已越过保留期尽头。
|
||||
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
||||
base := auditWindowHours
|
||||
if cur.M == auditModeFallback {
|
||||
base = auditFallbackWindowHours
|
||||
}
|
||||
w := cur.WindowHours
|
||||
if w <= 0 {
|
||||
w = base
|
||||
}
|
||||
if empty {
|
||||
if w *= 2; w > auditWindowMaxHours {
|
||||
w = auditWindowMaxHours
|
||||
}
|
||||
} else {
|
||||
w = base
|
||||
}
|
||||
end := cur.Start
|
||||
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
||||
return cur, true
|
||||
}
|
||||
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w, Q: cur.Q, M: cur.M}, false
|
||||
}
|
||||
|
||||
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
||||
// 表示已回溯到保留期尽头,无更早数据。
|
||||
type AuditBatchResult struct {
|
||||
Items []AuditEvent
|
||||
Cursor *AuditCursor
|
||||
Exhausted bool
|
||||
}
|
||||
|
||||
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
||||
// 保留事件;单批受页预算与时间预算双重约束,不足额也返回,由前端按需续查。
|
||||
// 倒序返回下,窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
||||
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||
return listAuditBatch(ctx, c.newAuditFetchers(cred, region), cur, limit)
|
||||
}
|
||||
|
||||
// listAuditBatch 是批式回溯的通道无关内核,取页函数注入便于测试。
|
||||
func listAuditBatch(ctx context.Context, f auditFetchers, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||
res := AuditBatchResult{Items: []AuditEvent{}}
|
||||
windowHasKept := false
|
||||
deadline := time.Now().Add(auditBatchTimeBudget)
|
||||
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit && time.Now().Before(deadline); budget-- {
|
||||
items, next, nextCur, err := fetchAuditPage(ctx, f, cur)
|
||||
if err != nil {
|
||||
return AuditBatchResult{}, err
|
||||
}
|
||||
cur = nextCur
|
||||
before := len(res.Items)
|
||||
res.Items = appendKeptAuditEvents(res.Items, filterAuditTerm(items, cur))
|
||||
windowHasKept = windowHasKept || len(res.Items) > before
|
||||
if next != "" {
|
||||
cur.Page = next
|
||||
continue
|
||||
}
|
||||
adv, done := cur.advance(time.Now(), !windowHasKept)
|
||||
if done {
|
||||
res.Exhausted = true
|
||||
sortAuditEvents(res.Items)
|
||||
return res, nil
|
||||
}
|
||||
cur, windowHasKept = adv, false
|
||||
}
|
||||
sortAuditEvents(res.Items)
|
||||
res.Cursor = &cur
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// fetchAuditPage 按游标模式取一页;Search 主路报「配额为零」时切到回退游标
|
||||
// 并立即用 Audit API 重试,后续批次凭游标模式直达回退通道不再试错。
|
||||
func fetchAuditPage(ctx context.Context, f auditFetchers, cur AuditCursor) ([]AuditEvent, string, AuditCursor, error) {
|
||||
if cur.M == auditModeFallback {
|
||||
items, next, err := f.audit(ctx, cur)
|
||||
return items, next, cur, err
|
||||
}
|
||||
items, next, err := f.search(ctx, cur)
|
||||
if err != nil && isSearchQuotaZero(err) {
|
||||
cur = cur.toFallback()
|
||||
items, next, err = f.audit(ctx, cur)
|
||||
}
|
||||
return items, next, cur, err
|
||||
}
|
||||
|
||||
// filterAuditTerm 关键字精筛:只认列表可见字段(matchesAuditTerm),两条通道
|
||||
// 语义一致。Search 主路的 logContent 全文条件会命中隐藏认证元数据(如
|
||||
// opc-principal 头里的 ttype:login),只作粗筛减少翻页,不作为最终判定。
|
||||
func filterAuditTerm(items []AuditEvent, cur AuditCursor) []AuditEvent {
|
||||
if cur.Q == "" {
|
||||
return items
|
||||
}
|
||||
out := items[:0]
|
||||
for _, ev := range items {
|
||||
if matchesAuditTerm(ev, cur.Q) {
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchesAuditTerm 判断事件是否命中关键字:不区分大小写的包含匹配,
|
||||
// * 作为通配分段、各段都出现即命中,近似 Search 通道的 logContent 语义。
|
||||
func matchesAuditTerm(ev AuditEvent, q string) bool {
|
||||
hay := strings.ToLower(strings.Join([]string{
|
||||
ev.EventName, ev.Source, ev.ResourceName, ev.CompartmentName,
|
||||
ev.PrincipalName, ev.IPAddress, ev.Status, ev.RequestAction, ev.RequestPath,
|
||||
}, "\n"))
|
||||
for _, part := range strings.Split(strings.ToLower(q), "*") {
|
||||
if part != "" && !strings.Contains(hay, part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// auditClient 构造区域化的 Audit API 客户端(回退通道)。
|
||||
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
@@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl
|
||||
return ac, nil
|
||||
}
|
||||
|
||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||
ac, err := c.auditClient(cred, region)
|
||||
if err != nil {
|
||||
return AuditEventsResult{}, err
|
||||
}
|
||||
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
|
||||
req := audit.ListEventsRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
||||
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
||||
}
|
||||
if page != "" {
|
||||
req.Page = &page
|
||||
}
|
||||
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||
for i := 0; i < maxAuditPages; i++ {
|
||||
resp, err := ac.ListEvents(ctx, req)
|
||||
if err != nil {
|
||||
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
appendAuditEvents(&result, resp.Items)
|
||||
if resp.OpcNextPage == nil {
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
req.Page = resp.OpcNextPage
|
||||
}
|
||||
result.Truncated = true
|
||||
result.NextPage = deref(req.Page)
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
||||
result.Items = appendKeptAuditEvents(result.Items, items)
|
||||
}
|
||||
|
||||
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
|
||||
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
|
||||
for _, ev := range items {
|
||||
out := toAuditEvent(ev)
|
||||
if !keepAuditEvent(out) {
|
||||
continue
|
||||
}
|
||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||
out.Raw = raw
|
||||
}
|
||||
dst = append(dst, out)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
||||
|
||||
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
|
||||
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
|
||||
const (
|
||||
auditWindowHours = 24
|
||||
auditWindowMaxHours = 720
|
||||
auditRetentionDays = 365
|
||||
)
|
||||
|
||||
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
|
||||
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
|
||||
type AuditCursor struct {
|
||||
Start time.Time `json:"s"`
|
||||
End time.Time `json:"e"`
|
||||
Page string `json:"p,omitempty"`
|
||||
WindowHours int `json:"w"`
|
||||
}
|
||||
|
||||
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
||||
func NewAuditCursor(now time.Time) AuditCursor {
|
||||
end := now.UTC().Truncate(time.Minute)
|
||||
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
||||
}
|
||||
|
||||
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
||||
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
|
||||
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
||||
w := cur.WindowHours
|
||||
if w <= 0 {
|
||||
w = auditWindowHours
|
||||
}
|
||||
if empty {
|
||||
if w *= 2; w > auditWindowMaxHours {
|
||||
w = auditWindowMaxHours
|
||||
}
|
||||
} else {
|
||||
w = auditWindowHours
|
||||
}
|
||||
end := cur.Start
|
||||
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
||||
return cur, true
|
||||
}
|
||||
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
|
||||
}
|
||||
|
||||
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
||||
// 表示已回溯到保留期尽头,无更早数据。
|
||||
type AuditBatchResult struct {
|
||||
Items []AuditEvent
|
||||
Cursor *AuditCursor
|
||||
Exhausted bool
|
||||
}
|
||||
|
||||
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
||||
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
|
||||
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
||||
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||
ac, err := c.auditClient(cred, region)
|
||||
if err != nil {
|
||||
return AuditBatchResult{}, err
|
||||
}
|
||||
res := AuditBatchResult{Items: []AuditEvent{}}
|
||||
windowHasKept := false
|
||||
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
|
||||
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
|
||||
if err != nil {
|
||||
return AuditBatchResult{}, err
|
||||
}
|
||||
before := len(res.Items)
|
||||
res.Items = appendKeptAuditEvents(res.Items, items)
|
||||
windowHasKept = windowHasKept || len(res.Items) > before
|
||||
if next != "" {
|
||||
cur.Page = next
|
||||
continue
|
||||
}
|
||||
nextCur, done := cur.advance(time.Now(), !windowHasKept)
|
||||
if done {
|
||||
res.Exhausted = true
|
||||
sortAuditEvents(res.Items)
|
||||
return res, nil
|
||||
}
|
||||
cur, windowHasKept = nextCur, false
|
||||
}
|
||||
sortAuditEvents(res.Items)
|
||||
res.Cursor = &cur
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// listAuditPage 拉取当前游标位置的一页原始事件。
|
||||
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
|
||||
// listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定
|
||||
// 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文,
|
||||
// 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染。
|
||||
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
req := audit.ListEventsRequest{
|
||||
CompartmentId: &tenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||
@@ -214,42 +399,18 @@ func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
return resp.Items, deref(resp.OpcNextPage), nil
|
||||
items := make([]AuditEvent, 0, len(resp.Items))
|
||||
for _, ev := range resp.Items {
|
||||
out := toAuditEvent(ev)
|
||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||
out.Raw = raw
|
||||
}
|
||||
items = append(items, out)
|
||||
}
|
||||
return items, deref(resp.OpcNextPage), nil
|
||||
}
|
||||
|
||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||
var auditInternalCIDRs = func() []*net.IPNet {
|
||||
out := make([]*net.IPNet, 0, 4)
|
||||
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
out = append(out, block)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// keepAuditEvent 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗),
|
||||
// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调;
|
||||
// 无 IP 的事件(控制面内部)保留。
|
||||
func keepAuditEvent(ev AuditEvent) bool {
|
||||
if ev.EventName == "SummarizeMetricsData" {
|
||||
return false
|
||||
}
|
||||
if ev.IPAddress == "" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(ev.IPAddress)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
for _, block := range auditInternalCIDRs {
|
||||
if block.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||
// toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||
if ev.EventTime != nil {
|
||||
@@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||
return out
|
||||
}
|
||||
|
||||
// searchAuditPage 拉取游标窗口内按 datetime 倒序的一页审计事件(已映射未过滤)。
|
||||
func searchAuditPage(ctx context.Context, sc loggingsearch.LogSearchClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
req := loggingsearch.SearchLogsRequest{
|
||||
SearchLogsDetails: loggingsearch.SearchLogsDetails{
|
||||
TimeStart: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||
TimeEnd: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
|
||||
SearchQuery: common.String(auditSearchQuery(tenancyOCID, cur.Q)),
|
||||
},
|
||||
Limit: common.Int(auditSearchPageLimit),
|
||||
}
|
||||
if cur.Page != "" {
|
||||
req.Page = &cur.Page
|
||||
}
|
||||
resp, err := sc.SearchLogs(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("search audit logs: %w", err)
|
||||
}
|
||||
items := make([]AuditEvent, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
if ev, ok := toSearchAuditEvent(r); ok {
|
||||
items = append(items, ev)
|
||||
}
|
||||
}
|
||||
return items, deref(resp.OpcNextPage), nil
|
||||
}
|
||||
|
||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||
var auditInternalCIDRs = func() []*net.IPNet {
|
||||
out := make([]*net.IPNet, 0, 4)
|
||||
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
out = append(out, block)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// keepAuditEvent 保留有展示价值的事件:SummarizeMetricsData 已在检索语句里
|
||||
// 先滤(此处兜底),内网地址发起的服务互调用 CIDR 判断(查询语言不便表达);
|
||||
// 无 IP 的事件(控制面内部)保留。
|
||||
func keepAuditEvent(ev AuditEvent) bool {
|
||||
if ev.EventName == "SummarizeMetricsData" {
|
||||
return false
|
||||
}
|
||||
if ev.IPAddress == "" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(ev.IPAddress)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
for _, block := range auditInternalCIDRs {
|
||||
if block.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// searchAuditContent 是 _Audit 日志 logContent 的字段投影,只取列表展示所需;
|
||||
// identity / request / response 可能为 null,零值即缺省。
|
||||
type searchAuditContent struct {
|
||||
ID string `json:"id"`
|
||||
Time *time.Time `json:"time"`
|
||||
Source string `json:"source"`
|
||||
Data struct {
|
||||
EventName string `json:"eventName"`
|
||||
ResourceName string `json:"resourceName"`
|
||||
CompartmentName string `json:"compartmentName"`
|
||||
Identity struct {
|
||||
PrincipalName string `json:"principalName"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
} `json:"identity"`
|
||||
Request struct {
|
||||
Action string `json:"action"`
|
||||
Path string `json:"path"`
|
||||
} `json:"request"`
|
||||
Response struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"response"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// toSearchAuditEvent 把日志搜索结果压平为列表 DTO;Raw 即 logContent 原文。
|
||||
// 结构不符的条目丢弃(返回 false),不因单条脏数据整页失败。
|
||||
func toSearchAuditEvent(r loggingsearch.SearchResult) (AuditEvent, bool) {
|
||||
if r.Data == nil {
|
||||
return AuditEvent{}, false
|
||||
}
|
||||
b, err := json.Marshal(r.Data)
|
||||
if err != nil {
|
||||
return AuditEvent{}, false
|
||||
}
|
||||
var hit struct {
|
||||
LogContent json.RawMessage `json:"logContent"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &hit); err != nil || len(hit.LogContent) == 0 {
|
||||
return AuditEvent{}, false
|
||||
}
|
||||
var content searchAuditContent
|
||||
if err := json.Unmarshal(hit.LogContent, &content); err != nil {
|
||||
return AuditEvent{}, false
|
||||
}
|
||||
ev := searchContentToEvent(content)
|
||||
ev.Raw = hit.LogContent
|
||||
return ev, true
|
||||
}
|
||||
|
||||
// searchContentToEvent 把投影字段填入列表 DTO;Raw 由调用方设置。
|
||||
func searchContentToEvent(c searchAuditContent) AuditEvent {
|
||||
return AuditEvent{
|
||||
EventId: c.ID,
|
||||
EventTime: c.Time,
|
||||
EventName: c.Data.EventName,
|
||||
Source: c.Source,
|
||||
ResourceName: c.Data.ResourceName,
|
||||
CompartmentName: c.Data.CompartmentName,
|
||||
PrincipalName: c.Data.Identity.PrincipalName,
|
||||
IPAddress: c.Data.Identity.IPAddress,
|
||||
Status: c.Data.Response.Status,
|
||||
RequestAction: c.Data.Request.Action,
|
||||
RequestPath: c.Data.Request.Path,
|
||||
}
|
||||
}
|
||||
|
||||
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||
func sortAuditEvents(items []AuditEvent) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
|
||||
+294
-27
@@ -1,14 +1,262 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||
)
|
||||
|
||||
// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。
|
||||
var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`)
|
||||
|
||||
func TestIsSearchQuotaZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"配额为零真实报错", quotaZeroErr, true},
|
||||
{"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false},
|
||||
{"其他错误", errors.New("service unavailable"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isSearchQuotaZero(tc.err); got != tc.want {
|
||||
t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchFallback(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
searchCalls, auditCalls := 0, 0
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
searchCalls++
|
||||
return nil, "", quotaZeroErr
|
||||
},
|
||||
audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
auditCalls++
|
||||
if cur.M != auditModeFallback || cur.Page != "" {
|
||||
t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur)
|
||||
}
|
||||
ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et}
|
||||
return []AuditEvent{ev}, "", nil
|
||||
},
|
||||
}
|
||||
res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("配额为零应回退成功, got %v", err)
|
||||
}
|
||||
if searchCalls != 1 {
|
||||
t.Fatalf("Search 只应试错一次, got %d", searchCalls)
|
||||
}
|
||||
if len(res.Items) < 3 || auditCalls < 3 {
|
||||
t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls)
|
||||
}
|
||||
if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours {
|
||||
t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("回退模式游标不应再调用 Search 通道")
|
||||
return nil, "", nil
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil
|
||||
},
|
||||
}
|
||||
cur := NewAuditCursor(time.Now()).toFallback()
|
||||
if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil {
|
||||
t.Fatalf("回退模式续查失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchSearchErrorNoFallback(t *testing.T) {
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
return nil, "", errors.New("search audit logs: timeout")
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("普通错误不应触发回退")
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil {
|
||||
t.Fatal("普通错误应原样上抛")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAuditTerm(t *testing.T) {
|
||||
login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"}
|
||||
noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"}
|
||||
items := []AuditEvent{login, noise}
|
||||
cases := []struct {
|
||||
name string
|
||||
cur AuditCursor
|
||||
want int
|
||||
}{
|
||||
{"无关键字原样放行", AuditCursor{}, 2},
|
||||
{"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1},
|
||||
{"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want {
|
||||
t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchSearchTermPrecision(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
// 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login)
|
||||
f := auditFetchers{
|
||||
search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
return []AuditEvent{
|
||||
{EventId: "hit", EventName: "InteractiveLogin", EventTime: &et},
|
||||
{EventId: "noise1", EventName: "ListRecommendations", EventTime: &et},
|
||||
{EventId: "noise2", EventName: "SearchLogs", EventTime: &et},
|
||||
}, "", nil
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("Search 正常时不应走回退")
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
cur := NewAuditCursor(time.Now())
|
||||
cur.Q = "login"
|
||||
res, err := listAuditBatch(context.Background(), f, cur, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("listAuditBatch() err = %v", err)
|
||||
}
|
||||
if len(res.Items) != 1 || res.Items[0].EventId != "hit" {
|
||||
t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesAuditTerm(t *testing.T) {
|
||||
ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"}
|
||||
cases := []struct {
|
||||
name string
|
||||
q string
|
||||
want bool
|
||||
}{
|
||||
{"不区分大小写", "listvnic", true},
|
||||
{"通配分段都出现", "List*Attachments", true},
|
||||
{"资源名命中", "WEB-1", true},
|
||||
{"未命中", "TerminateInstance", false},
|
||||
{"通配缺段不命中", "List*Volume", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := matchesAuditTerm(ev, tc.q); got != tc.want {
|
||||
t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。
|
||||
func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult {
|
||||
t.Helper()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("fixture 不是合法 JSON: %v", err)
|
||||
}
|
||||
return loggingsearch.SearchResult{Data: &v}
|
||||
}
|
||||
|
||||
func TestToSearchAuditEvent(t *testing.T) {
|
||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantOK bool
|
||||
want AuditEvent
|
||||
}{
|
||||
{
|
||||
name: "全字段齐全",
|
||||
data: `{"datetime":1783074600000,"logContent":{
|
||||
"id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi",
|
||||
"data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod",
|
||||
"identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"},
|
||||
"request":{"action":"DELETE","path":"/20160918/instances/ocid1..."},
|
||||
"response":{"status":"204"}}}}`,
|
||||
wantOK: true,
|
||||
want: AuditEvent{
|
||||
EventId: "evt-abc",
|
||||
EventTime: &eventTime,
|
||||
EventName: "TerminateInstance",
|
||||
Source: "ComputeApi",
|
||||
ResourceName: "web-1",
|
||||
CompartmentName: "prod",
|
||||
PrincipalName: "api-admin",
|
||||
IPAddress: "1.2.3.4",
|
||||
Status: "204",
|
||||
RequestAction: "DELETE",
|
||||
RequestPath: "/20160918/instances/ocid1...",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity/request/response 为 null 时只保留信封字段",
|
||||
data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi",
|
||||
"data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`,
|
||||
wantOK: true,
|
||||
want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"},
|
||||
},
|
||||
{
|
||||
name: "缺 logContent 丢弃",
|
||||
data: `{"datetime":1783074600000}`,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "logContent 结构不符丢弃",
|
||||
data: `{"logContent":"plain-text"}`,
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data))
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(got.Raw) == 0 {
|
||||
t.Fatalf("Raw 应携带 logContent 原文")
|
||||
}
|
||||
if !auditEventEqual(got, tt.want) {
|
||||
t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSearchAuditEventNilData(t *testing.T) {
|
||||
if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok {
|
||||
t.Fatal("Data 为 nil 应丢弃")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToAuditEvent(t *testing.T) {
|
||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: AuditEvent{
|
||||
EventId: "evt-abc",
|
||||
EventTime: &eventTime,
|
||||
EventName: "TerminateInstance",
|
||||
Source: "ComputeApi",
|
||||
ResourceName: "web-1",
|
||||
CompartmentName: "prod",
|
||||
PrincipalName: "api-admin",
|
||||
IPAddress: "1.2.3.4",
|
||||
Status: "204",
|
||||
RequestAction: "DELETE",
|
||||
RequestPath: "/20160918/instances/ocid1...",
|
||||
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
|
||||
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
|
||||
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
|
||||
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Data 为 nil 时只保留信封字段",
|
||||
ev: audit.AuditEvent{
|
||||
Source: common.String("VcnApi"),
|
||||
EventTime: &common.SDKTime{Time: eventTime},
|
||||
},
|
||||
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
||||
},
|
||||
{
|
||||
name: "嵌套局部 nil 各自安全跳过",
|
||||
ev: audit.AuditEvent{
|
||||
Data: &audit.Data{
|
||||
EventName: common.String("GetInstance"),
|
||||
Identity: nil,
|
||||
Request: &audit.Request{Path: common.String("/instances")},
|
||||
Response: nil,
|
||||
},
|
||||
},
|
||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||
},
|
||||
{
|
||||
name: "空事件全部零值",
|
||||
ev: audit.AuditEvent{},
|
||||
want: AuditEvent{},
|
||||
},
|
||||
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditSearchQuery(t *testing.T) {
|
||||
const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'`
|
||||
cases := []struct {
|
||||
name string
|
||||
term string
|
||||
want string
|
||||
}{
|
||||
{"无关键字", "", prefix + ` | sort by datetime desc`},
|
||||
{"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`},
|
||||
{"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`},
|
||||
{"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want {
|
||||
t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditTerm(t *testing.T) {
|
||||
if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" {
|
||||
t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got)
|
||||
}
|
||||
long := strings.Repeat("a", 300)
|
||||
if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen {
|
||||
t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||
func auditEventEqual(a, b AuditEvent) bool {
|
||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||
@@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) {
|
||||
Start: now.Add(-24 * time.Hour),
|
||||
End: now,
|
||||
WindowHours: 24,
|
||||
Q: "kw",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) {
|
||||
}{
|
||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||
{"空窗倍增", base, true, 48, false},
|
||||
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
|
||||
{"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false},
|
||||
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
||||
{"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false},
|
||||
{"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false},
|
||||
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) {
|
||||
if next.Page != "" {
|
||||
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
||||
}
|
||||
if next.Q != tc.cur.Q {
|
||||
t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q)
|
||||
}
|
||||
if next.M != tc.cur.M {
|
||||
t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
sem = make(chan struct{}, 8)
|
||||
primarySeen = make(map[*Instance]bool)
|
||||
)
|
||||
for _, att := range attResp.Items {
|
||||
inst, ok := active[deref(att.InstanceId)]
|
||||
@@ -416,16 +417,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
inst.SubnetID = deref(vnicResp.SubnetId)
|
||||
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
||||
inst.PublicIP = deref(vnicResp.PublicIp)
|
||||
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
||||
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
|
||||
mu.Unlock()
|
||||
}(att.VnicId, inst)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
|
||||
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
|
||||
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
|
||||
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
|
||||
isPrimary := v.IsPrimary != nil && *v.IsPrimary
|
||||
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
|
||||
return
|
||||
}
|
||||
inst.SubnetID = deref(v.SubnetId)
|
||||
inst.PrivateIP = deref(v.PrivateIp)
|
||||
inst.PublicIP = deref(v.PublicIp)
|
||||
inst.Ipv6Addresses = v.Ipv6Addresses
|
||||
if isPrimary {
|
||||
primarySeen[inst] = true
|
||||
}
|
||||
}
|
||||
|
||||
func toInstance(inst core.Instance) Instance {
|
||||
out := Instance{
|
||||
ID: deref(inst.Id),
|
||||
|
||||
@@ -5,9 +5,51 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
func TestApplyVnicAddrs(t *testing.T) {
|
||||
primary := core.Vnic{
|
||||
IsPrimary: common.Bool(true),
|
||||
SubnetId: common.String("sub-a"),
|
||||
PrivateIp: common.String("10.0.0.2"),
|
||||
PublicIp: common.String("1.1.1.1"),
|
||||
}
|
||||
secondary := core.Vnic{
|
||||
IsPrimary: common.Bool(false),
|
||||
SubnetId: common.String("sub-b"),
|
||||
PrivateIp: common.String("10.0.0.9"),
|
||||
}
|
||||
secondary2 := core.Vnic{
|
||||
SubnetId: common.String("sub-c"),
|
||||
PrivateIp: common.String("10.0.0.7"),
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
order []core.Vnic
|
||||
wantPrivate string
|
||||
wantSubnet string
|
||||
}{
|
||||
{name: "主卡先到不被次卡覆盖", order: []core.Vnic{primary, secondary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||
{name: "次卡先兜底主卡后覆盖", order: []core.Vnic{secondary, primary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||
{name: "无主卡时先到者保留", order: []core.Vnic{secondary, secondary2}, wantPrivate: "10.0.0.9", wantSubnet: "sub-b"},
|
||||
{name: "仅次卡也能兜底", order: []core.Vnic{secondary2}, wantPrivate: "10.0.0.7", wantSubnet: "sub-c"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inst := &Instance{}
|
||||
seen := make(map[*Instance]bool)
|
||||
for _, v := range tt.order {
|
||||
applyVnicAddrs(inst, v, seen)
|
||||
}
|
||||
if inst.PrivateIP != tt.wantPrivate || inst.SubnetID != tt.wantSubnet {
|
||||
t.Errorf("got (%q, %q), want (%q, %q)", inst.PrivateIP, inst.SubnetID, tt.wantPrivate, tt.wantSubnet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellSingleQuote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+135
-12
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -61,20 +62,72 @@ type AiGatewayService struct {
|
||||
onChannelsChanged func(context.Context)
|
||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||
filterDeprecated atomic.Bool
|
||||
// streamGuard* 是 Responses 流式保险丝(instructions+tools 合计超阈值改非流式)
|
||||
streamGuardEnabled atomic.Bool
|
||||
streamGuardKB atomic.Int64
|
||||
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
|
||||
grokWebSearch atomic.Bool
|
||||
grokXSearch atomic.Bool
|
||||
}
|
||||
|
||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
||||
s.filterDeprecated.Store(row.Value == "1")
|
||||
}
|
||||
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
|
||||
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
|
||||
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
|
||||
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
|
||||
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
|
||||
return s
|
||||
}
|
||||
|
||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
|
||||
const (
|
||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关,缺省关闭。
|
||||
settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||
// settingAiStreamGuardEnabled / settingAiStreamGuardKB 是流式保险丝开关与
|
||||
// 阈值(KB),缺省开、60(上游对 instructions+tools >≈64.5KB 流式静默断流)。
|
||||
settingAiStreamGuardEnabled = "ai_stream_guard_enabled"
|
||||
settingAiStreamGuardKB = "ai_stream_guard_kb"
|
||||
// settingAiGrokWebSearch / settingAiGrokXSearch 是 grok 搜索工具默认注入
|
||||
// 开关,缺省开。
|
||||
settingAiGrokWebSearch = "ai_grok_web_search"
|
||||
settingAiGrokXSearch = "ai_grok_x_search"
|
||||
)
|
||||
|
||||
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
|
||||
const defaultStreamGuardKB = 60
|
||||
|
||||
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
|
||||
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||
return def
|
||||
}
|
||||
return row.Value == "1"
|
||||
}
|
||||
|
||||
// loadIntSetting 读 settings 表整数键,无行或解析失败时返回缺省。
|
||||
func loadIntSetting(db *gorm.DB, key string, def int) int {
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(row.Value)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// saveBoolSetting 持久化布尔键。
|
||||
func (s *AiGatewayService) saveBoolSetting(ctx context.Context, key string, on bool) error {
|
||||
value := "0"
|
||||
if on {
|
||||
value = "1"
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
|
||||
}
|
||||
|
||||
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||
@@ -82,18 +135,54 @@ func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.L
|
||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||
value := "0"
|
||||
if on {
|
||||
value = "1"
|
||||
}
|
||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
||||
if err != nil {
|
||||
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
|
||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||
}
|
||||
s.filterDeprecated.Store(on)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamGuard 返回流式保险丝开关与阈值(KB)。
|
||||
func (s *AiGatewayService) StreamGuard() (bool, int) {
|
||||
return s.streamGuardEnabled.Load(), int(s.streamGuardKB.Load())
|
||||
}
|
||||
|
||||
// SetStreamGuard 持久化并即时生效流式保险丝;kb 限定 1..1024。
|
||||
func (s *AiGatewayService) SetStreamGuard(ctx context.Context, on bool, kb int) error {
|
||||
if kb < 1 || kb > 1024 {
|
||||
return fmt.Errorf("流式保险丝阈值须在 1..1024 KB, 收到 %d", kb)
|
||||
}
|
||||
if err := s.saveBoolSetting(ctx, settingAiStreamGuardEnabled, on); err != nil {
|
||||
return fmt.Errorf("保存流式保险丝开关: %w", err)
|
||||
}
|
||||
err := s.db.WithContext(ctx).
|
||||
Save(&model.Setting{Key: settingAiStreamGuardKB, Value: strconv.Itoa(kb)}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存流式保险丝阈值: %w", err)
|
||||
}
|
||||
s.streamGuardEnabled.Store(on)
|
||||
s.streamGuardKB.Store(int64(kb))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrokSearch 返回 grok 服务端搜索工具默认注入开关(web_search, x_search)。
|
||||
func (s *AiGatewayService) GrokSearch() (bool, bool) {
|
||||
return s.grokWebSearch.Load(), s.grokXSearch.Load()
|
||||
}
|
||||
|
||||
// SetGrokSearch 持久化并即时生效 grok 搜索工具默认注入开关。
|
||||
func (s *AiGatewayService) SetGrokSearch(ctx context.Context, web, x bool) error {
|
||||
if err := s.saveBoolSetting(ctx, settingAiGrokWebSearch, web); err != nil {
|
||||
return fmt.Errorf("保存 grok web_search 开关: %w", err)
|
||||
}
|
||||
if err := s.saveBoolSetting(ctx, settingAiGrokXSearch, x); err != nil {
|
||||
return fmt.Errorf("保存 grok x_search 开关: %w", err)
|
||||
}
|
||||
s.grokWebSearch.Store(web)
|
||||
s.grokXSearch.Store(x)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
||||
s.onChannelsChanged = fn
|
||||
@@ -661,6 +750,40 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// AggregatedModel 是聚合模型目录条目(设置页黑名单添加弹窗用)。
|
||||
type AggregatedModel struct {
|
||||
Name string `json:"name"`
|
||||
Capability string `json:"capability"`
|
||||
}
|
||||
|
||||
// AggregatedModels 返回启用渠道去重后的模型目录(含能力);空能力归一为 CHAT。
|
||||
// 与模型列表口径一致:「过滤弃用」开启时弃用模型不出现在目录中。
|
||||
func (s *AiGatewayService) AggregatedModels(ctx context.Context) ([]AggregatedModel, error) {
|
||||
q := s.db.WithContext(ctx).
|
||||
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
if err := q.Order("ai_model_caches.name ASC").Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("聚合模型目录: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []AggregatedModel{}
|
||||
for _, r := range rows {
|
||||
if seen[r.Name] {
|
||||
continue
|
||||
}
|
||||
seen[r.Name] = true
|
||||
cap := r.Capability
|
||||
if cap == "" {
|
||||
cap = "CHAT"
|
||||
}
|
||||
out = append(out, AggregatedModel{Name: r.Name, Capability: cap})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
||||
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
||||
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
||||
|
||||
@@ -1060,3 +1060,67 @@ func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
||||
t.Errorf("流内容未透传: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAiRuntimeSettings 断言流式保险丝与 grok 注入开关的缺省值、往返与持久化。
|
||||
func TestAiRuntimeSettings(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
ctx := context.Background()
|
||||
|
||||
if on, kb := gw.StreamGuard(); !on || kb != 60 {
|
||||
t.Fatalf("保险丝缺省应为 开/60, got %v/%d", on, kb)
|
||||
}
|
||||
if web, x := gw.GrokSearch(); !web || !x {
|
||||
t.Fatalf("grok 注入缺省应双开, got %v/%v", web, x)
|
||||
}
|
||||
for _, bad := range []int{0, -1, 1025} {
|
||||
if err := gw.SetStreamGuard(ctx, true, bad); err == nil {
|
||||
t.Errorf("阈值 %d 应报错", bad)
|
||||
}
|
||||
}
|
||||
if err := gw.SetStreamGuard(ctx, false, 80); err != nil {
|
||||
t.Fatalf("SetStreamGuard: %v", err)
|
||||
}
|
||||
if err := gw.SetGrokSearch(ctx, false, true); err != nil {
|
||||
t.Fatalf("SetGrokSearch: %v", err)
|
||||
}
|
||||
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||
if on, kb := gw2.StreamGuard(); on || kb != 80 {
|
||||
t.Errorf("重建后保险丝应为 关/80, got %v/%d", on, kb)
|
||||
}
|
||||
if web, x := gw2.GrokSearch(); web || !x {
|
||||
t.Errorf("重建后 grok 注入应为 关/开, got %v/%v", web, x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregatedModelsFilterDeprecated 断言聚合目录与模型列表口径一致:
|
||||
// 「过滤弃用」开启时弃用模型不出现,关闭时出现;空能力归一为 CHAT。
|
||||
func TestAggregatedModelsFilterDeprecated(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
dep := time.Now().Add(-24 * time.Hour)
|
||||
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||
if err := gw.db.Create(old).Error; err != nil {
|
||||
t.Fatalf("seed deprecated cache: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
items, err := gw.AggregatedModels(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AggregatedModels: %v", err)
|
||||
}
|
||||
if len(items) != 2 || items[0].Capability == "" {
|
||||
t.Fatalf("开关关:应含弃用模型且能力归一, got %+v", items)
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||
}
|
||||
items, err = gw.AggregatedModels(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AggregatedModels(过滤): %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Name == "meta.llama-old" {
|
||||
t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +320,63 @@ func respToolDrop(tool map[string]any) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// RespStreamUpgradeLimit 是流式直通的请求体安全上限。实测(2026-07,xai 面):
|
||||
// 超过 ~82KB 的流式请求上游会在推理阶段掐断流(非流式不受影响,纯体积触发,
|
||||
// 与工具构成无关),预留余量取 76KB;超限时网关改走非流式上游并合成 SSE。
|
||||
const RespStreamUpgradeLimit = 76 * 1024
|
||||
// RespGuardBytes 返回请求体中 instructions 与 tools 两字段的原始字节数之和,
|
||||
// 流式保险丝据此判定。上游对二者合计 >≈64.5KB 的流式请求会在推理阶段静默断流
|
||||
// (纯 EOF,input 正文不计入;2026-07-16 实测仍存在),阈值由设置页 AI Tab 配置。
|
||||
// 解析失败返回 0(放行,交由上游正常报错)。
|
||||
func RespGuardBytes(body []byte) int {
|
||||
var probe struct {
|
||||
Instructions json.RawMessage `json:"instructions"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &probe); err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(probe.Instructions) + len(probe.Tools)
|
||||
}
|
||||
|
||||
// RespInjectGrokTools 为 xai. 前缀模型默认注入服务端搜索工具:开关开启且请求
|
||||
// tools 中不存在同名工具时追加 {"type":"web_search"} / {"type":"x_search"};
|
||||
// 已存在(含任意参数形态)不覆盖。返回改写后 body 与注入清单(观测日志用);
|
||||
// 模型不匹配、两开关全关或解析失败时原样返回。
|
||||
func RespInjectGrokTools(body []byte, model string, web, x bool) ([]byte, []string) {
|
||||
if !strings.HasPrefix(model, "xai.") || (!web && !x) {
|
||||
return body, nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||
dec.UseNumber()
|
||||
var m map[string]any
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return body, nil
|
||||
}
|
||||
tools, _ := m["tools"].([]any)
|
||||
missing := map[string]bool{"web_search": web, "x_search": x}
|
||||
for _, t := range tools {
|
||||
tool, ok := t.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if typ, _ := tool["type"].(string); missing[typ] {
|
||||
missing[typ] = false
|
||||
}
|
||||
}
|
||||
var injected []string
|
||||
for _, typ := range []string{"web_search", "x_search"} {
|
||||
if missing[typ] {
|
||||
tools = append(tools, map[string]any{"type": typ})
|
||||
injected = append(injected, typ)
|
||||
}
|
||||
}
|
||||
if len(injected) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
m["tools"] = tools
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return body, nil
|
||||
}
|
||||
return out, injected
|
||||
}
|
||||
|
||||
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
||||
// 原样保留。
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -329,6 +330,71 @@ func TestRespStreamCompletedUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespGuardBytes 断言保险丝计量:instructions+tools 原始字节和,缺字段计 0,
|
||||
// 解析失败返回 0 放行。
|
||||
func TestRespGuardBytes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
want int
|
||||
}{
|
||||
{"双字段", `{"instructions":"abcd","tools":[{"type":"web_search"}],"input":"xxxxxxxx"}`,
|
||||
len(`"abcd"`) + len(`[{"type":"web_search"}]`)},
|
||||
{"仅 instructions", `{"instructions":"abcd"}`, len(`"abcd"`)},
|
||||
{"均缺失 input 不计", `{"input":"xxxxxxxxxxxxxxxx"}`, 0},
|
||||
{"解析失败放行", `not-json`, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RespGuardBytes([]byte(tc.body)); got != tc.want {
|
||||
t.Fatalf("RespGuardBytes() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespInjectGrokTools 断言 grok 搜索工具默认注入:仅 xai. 模型、开关可控、
|
||||
// 已带同名工具不覆盖、注入清单正确。
|
||||
func TestRespInjectGrokTools(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body, model string
|
||||
web, x bool
|
||||
wantInjected []string
|
||||
wantContains []string
|
||||
}{
|
||||
{"非 xai 不注入", `{"tools":[]}`, "meta.llama-3.3", true, true, nil, nil},
|
||||
{"双开无 tools 字段注入两个", `{"model":"xai.grok-4.3"}`, "xai.grok-4.3", true, true,
|
||||
[]string{"web_search", "x_search"}, []string{`"web_search"`, `"x_search"`}},
|
||||
{"已带 web_search 只注入 x_search", `{"tools":[{"type":"web_search","filters":{"x":1}}]}`,
|
||||
"xai.grok-4.3", true, true, []string{"x_search"}, []string{`"filters"`}},
|
||||
{"开关全关不注入", `{"tools":[]}`, "xai.grok-4.3", false, false, nil, nil},
|
||||
{"仅开 x_search", `{"tools":[]}`, "xai.grok-4.3", false, true, []string{"x_search"}, nil},
|
||||
{"解析失败原样", `not-json`, "xai.grok-4.3", true, true, nil, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, injected := RespInjectGrokTools([]byte(tc.body), tc.model, tc.web, tc.x)
|
||||
if fmt.Sprint(injected) != fmt.Sprint(tc.wantInjected) {
|
||||
t.Fatalf("injected = %v, want %v", injected, tc.wantInjected)
|
||||
}
|
||||
if len(injected) == 0 && string(out) != tc.body {
|
||||
t.Fatalf("未注入时应原样返回: %s", out)
|
||||
}
|
||||
for _, sub := range append(tc.wantContains, toolTypes(injected)...) {
|
||||
if !strings.Contains(string(out), sub) {
|
||||
t.Fatalf("输出缺少 %s: %s", sub, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// toolTypes 把注入清单转为输出应包含的片段断言。
|
||||
func toolTypes(injected []string) []string {
|
||||
var out []string
|
||||
for _, typ := range injected {
|
||||
out = append(out, `{"type":"`+typ+`"}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
||||
func TestRespDisableStream(t *testing.T) {
|
||||
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||
|
||||
@@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to
|
||||
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
||||
|
||||
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
||||
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。
|
||||
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数;
|
||||
// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。
|
||||
type AuditQuery struct {
|
||||
Region string
|
||||
Cursor string
|
||||
Limit int
|
||||
Q string
|
||||
}
|
||||
|
||||
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
||||
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。
|
||||
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头;
|
||||
// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。
|
||||
type AuditEventsView struct {
|
||||
Items []oci.AuditEvent `json:"items"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Exhausted bool `json:"exhausted"`
|
||||
ScannedThrough *time.Time `json:"scannedThrough,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
||||
@@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
||||
if err != nil {
|
||||
return AuditEventsView{}, err
|
||||
}
|
||||
if q.Cursor == "" {
|
||||
cur.Q = oci.SanitizeAuditTerm(q.Q)
|
||||
}
|
||||
cred, err := s.credentialsByID(ctx, id)
|
||||
if err != nil {
|
||||
return AuditEventsView{}, err
|
||||
@@ -63,6 +70,7 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
||||
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
||||
if res.Cursor != nil {
|
||||
view.Cursor = encodeAuditCursor(*res.Cursor)
|
||||
view.ScannedThrough = &res.Cursor.End
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user