Compare commits
4
Commits
da7b29d2e3
..
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 |
@@ -20,6 +20,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
|||||||
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
||||||
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
||||||
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
||||||
|
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||||
| [Logging Guidelines](./logging-guidelines.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/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [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]
|
## [0.6.1]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.6.0
|
v0.7.1
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
**自托管的 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>
|
</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 任务,提供执行日志、重叠执行防护、熔断与结果通知
|
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 |
|
||||||
- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
- **身份与审计**:IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
- **通知与安全**:Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
||||||
- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
|
| **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。
|
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
|
||||||
|
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
|
||||||
|
|
||||||
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,19 +96,27 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 准备数据目录并启动:
|
2. 准备数据目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。
|
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
|
||||||
|
Docker Desktop 用户通常不需要执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
sudo chown 65532:65532 data
|
sudo chown 65532:65532 data
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动并检查状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 查看初始管理员密码并登录:
|
4. 查看初始管理员密码并登录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep '^ADMIN_PASSWORD=' .env
|
grep '^ADMIN_PASSWORD=' .env
|
||||||
@@ -99,7 +124,14 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
访问 `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
|
```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/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
|
chmod +x oci-portal-server-linux-amd64
|
||||||
|
|
||||||
# 复用上文生成并妥善保存的 .env。
|
# 复用上文生成并妥善保存的 .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
|
### Caddy
|
||||||
|
|
||||||
@@ -229,29 +265,31 @@ OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`do
|
|||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
| 变量 | 必填 | 默认值 | 说明 |
|
| 变量 | 使用条件 | 默认值 | 说明 |
|
||||||
| --- | :---: | --- | --- |
|
| --- | :---: | --- | --- |
|
||||||
| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||||
| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
|
||||||
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
|
||||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||||
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
||||||
| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
| `DB_DSN` | MySQL / PostgreSQL | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
||||||
| `PUBLIC_URL` | 否 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
| `PUBLIC_URL` | 可选 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
||||||
| `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
| `TZ` | 可选 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
||||||
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 可选 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
||||||
| `SWAGGER` | 否 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
| `SWAGGER` | 可选 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
||||||
| `GIN_MODE` | 否 | `release` | `debug` / `release` |
|
| `GIN_MODE` | 可选 | `release` | `debug` / `release` |
|
||||||
|
|
||||||
## 升级与备份
|
## 升级与备份
|
||||||
|
|
||||||
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
|
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
|
||||||
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
|
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
|
||||||
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
|
`data/oci-portal.db`,避免在线复制产生不一致快照。
|
||||||
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
|
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
|
||||||
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
|
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
|
||||||
|
会自动完成数据库迁移。
|
||||||
|
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
+113
-69
@@ -1,23 +1,33 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
# AI 网关
|
# AI 网关
|
||||||
|
|
||||||
|
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
|
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
|
||||||
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
|
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
|
||||||
>
|
> 兼容改写和实测边界以本文为准。
|
||||||
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
|
|
||||||
> 为准;协议差异、兼容改写和实测边界以本文为准。
|
|
||||||
|
|
||||||
**兼容快照:2026-07-15**
|
| 文档属性 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
## 快速导航
|
| 兼容快照 | **2026-07-16** |
|
||||||
|
| API 基址 | `/ai/v1` |
|
||||||
- [快速接入](#quick-start)
|
| 首选对话协议 | OpenAI Responses |
|
||||||
- [端点一览](#endpoints)
|
| 会话模式 | 无状态,客户端携带完整上下文 |
|
||||||
- [路由与全局行为](#routing)
|
|
||||||
- [Codex 接入](#codex)
|
|
||||||
- [已知限制](#limitations)
|
|
||||||
- [字段兼容矩阵](#compatibility)
|
|
||||||
- [实现索引](#implementation)
|
|
||||||
|
|
||||||
<a id="quick-start"></a>
|
<a id="quick-start"></a>
|
||||||
|
|
||||||
@@ -25,19 +35,13 @@
|
|||||||
|
|
||||||
### 基础地址与鉴权
|
### 基础地址与鉴权
|
||||||
|
|
||||||
```text
|
网关密钥在管理面板中创建。连接信息如下:
|
||||||
Base URL: https://<网关地址>/ai/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
网关密钥在管理面板中创建,支持以下任一请求头:
|
| 项目 | 配置 |
|
||||||
|
| --- | --- |
|
||||||
```http
|
| Base URL | `https://<网关地址>/ai/v1` |
|
||||||
Authorization: Bearer sk-...
|
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
|
||||||
```
|
| API Key 鉴权 | `x-api-key: sk-...` |
|
||||||
|
|
||||||
```http
|
|
||||||
x-api-key: sk-...
|
|
||||||
```
|
|
||||||
|
|
||||||
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||||
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||||
@@ -50,6 +54,15 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
-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>
|
<a id="endpoints"></a>
|
||||||
|
|
||||||
## 端点一览
|
## 端点一览
|
||||||
@@ -66,12 +79,14 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||||
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||||
|
|
||||||
选择建议:
|
### 如何选择协议
|
||||||
|
|
||||||
- 新客户端优先使用 **Responses**
|
| 使用场景 | 推荐接口 |
|
||||||
- Anthropic SDK 或 Claude 生态客户端使用 **Messages**
|
| --- | --- |
|
||||||
- 仅支持旧 OpenAI 对话协议的客户端使用 **Chat Completions**
|
| 新客户端、推理模型、服务端工具 | **Responses** |
|
||||||
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
|
| Anthropic SDK、Claude 生态客户端 | **Messages** |
|
||||||
|
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
|
||||||
|
| 向量、语音、重排与安全审核 | 对应专用端点 |
|
||||||
|
|
||||||
<a id="routing"></a>
|
<a id="routing"></a>
|
||||||
|
|
||||||
@@ -106,6 +121,14 @@ flowchart LR
|
|||||||
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||||
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
| 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>
|
<a id="codex"></a>
|
||||||
|
|
||||||
## Codex 接入
|
## Codex 接入
|
||||||
@@ -143,7 +166,7 @@ model = "xai.grok-4.3"
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> `codex-cli 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||||
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||||
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||||
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||||
@@ -165,38 +188,35 @@ Codex 工具兼容现状:
|
|||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
### 超大流式请求
|
### `instructions` / `tools` 大体量流式断流
|
||||||
|
|
||||||
上游流式断流有两个独立触发维度,状态不同:
|
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **instructions + tools 合计超过约 64.5 KB** 的流式请求,上游会在推理阶段
|
> `instructions` 与 `tools` 两个字段的原始 JSON 值合计超过约 **64.5 KB** 时,
|
||||||
> 静默断连(纯 EOF,无 error / 终态事件);同请求非流式总是成功,`input`
|
> 上游流式请求可能在推理阶段静默断连:连接直接 EOF,不发送 `error` 或终态事件。
|
||||||
> 正文完全不计入。2026-07-13 定位(字节级二分),**2026-07-16 复测仍存在**
|
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
||||||
> (70.4 KB 断 / 59.7 KB 过,Chicago,API Key 与签名行为一致)。
|
|
||||||
|
|
||||||
> [!NOTE]
|
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
||||||
> **完整请求体超过约 82 KB**(含 input)的纯体积断流(2026-07-15 定位)
|
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的
|
||||||
> **已被上游修复**:2026-07-16 复核 83 KB、真实 codex 形态 104.5 KB、200 KB、
|
`KB` 均按 `1024 B` 计算。
|
||||||
> 400 KB 流式均正常完成(Chicago 与 Phoenix 两区、签名与 API Key 两路径对照)。
|
|
||||||
|
|
||||||
网关当前行为:
|
| 协议 | 网关保护 | 客户端表现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Responses | 保险丝默认开启;超过 `60 KB` 时改走非流式上游,并合成最小 SSE 序列 | 结果语义保留,但不再增量输出 |
|
||||||
|
| Chat Completions / Messages | 客户端尚未收到内容就断流时,自动改用非流式重做 | 合成对应 chunk / event 序列 |
|
||||||
|
| 已开始输出的流 | 无法透明重试;调用日志记录提前终止 | 客户端可能只收到部分事件 |
|
||||||
|
|
||||||
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
|
Responses 合成的最小事件序列为:`response.created` →
|
||||||
非流式重做,并合成对应 chunk / event 序列(可兜住 64.5 KB 断流)
|
`response.output_item.done` → `response.completed`。保险丝可在
|
||||||
- **Responses**:直通协议中途无法透明重试(客户端已收到事件)。流式保险丝按
|
**设置 → AI → 流式保险丝** 调整或关闭。
|
||||||
`instructions + tools` 字节和判定:超过阈值时预防性改非流式上游 + 合成最小
|
|
||||||
SSE 事件序列(`response.created` → `response.output_item.done` →
|
|
||||||
`response.completed`),语义保留但无增量输出。默认开、60 KB,可在
|
|
||||||
**设置 → AI → 流式保险丝** 调整或关闭
|
|
||||||
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
|
|
||||||
部分事件
|
|
||||||
|
|
||||||
### grok 服务端搜索工具默认注入
|
<details>
|
||||||
|
<summary><strong>历史问题:完整请求体体积断流(已由上游修复)</strong></summary>
|
||||||
|
|
||||||
对 `xai.` 前缀模型的 Responses 请求,网关按开关默认注入 `web_search` /
|
2026-07-15 曾在完整请求体约 `82 KB`(含 `input`)时观测到纯体积流式断流。
|
||||||
`x_search` 工具;请求 tools 已包含同名工具时保持原样,不覆盖参数。默认双开,
|
2026-07-16 复核 `83 KB`、真实 Codex 形态 `104.5 KB`、`200 KB` 与 `400 KB`
|
||||||
可在 **设置 → AI → grok 服务端搜索工具** 关闭。
|
请求均正常完成;Chicago 与 Phoenix、签名与 API Key 两条路径结果一致。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### ZDR 与文件输入
|
### ZDR 与文件输入
|
||||||
|
|
||||||
@@ -208,16 +228,20 @@ File content is currently unsupported for ZDR customers
|
|||||||
|
|
||||||
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||||
|
|
||||||
### 规格与实测边界
|
|
||||||
|
|
||||||
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
|
|
||||||
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
|
||||||
|
|
||||||
<a id="compatibility"></a>
|
<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 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)
|
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||||
@@ -227,6 +251,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||||
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
| 标记 | 含义 |
|
| 标记 | 含义 |
|
||||||
| :---: | --- |
|
| :---: | --- |
|
||||||
| ✅ | 网关直接支持 |
|
| ✅ | 网关直接支持 |
|
||||||
@@ -234,6 +260,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
| 🔄 | 网关执行字段或协议转换后支持 |
|
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
|
||||||
|
<a id="compat-responses"></a>
|
||||||
|
|
||||||
### OpenAI Responses
|
### OpenAI Responses
|
||||||
|
|
||||||
**`POST /ai/v1/responses` · 无状态主接口**
|
**`POST /ai/v1/responses` · 无状态主接口**
|
||||||
@@ -255,12 +283,12 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||||
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
| `stream` | ◐ | 支持 SSE;网关按 instructions+tools 字节和触发预防性非流式回退(保险丝,默认开 60 KB,见[已知限制](#limitations)) |
|
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary><strong>工具兼容</strong></summary>
|
<summary><strong>工具兼容</strong></summary>
|
||||||
|
|
||||||
| 工具或参数 | 状态 | 网关行为 |
|
| 工具或参数 | 状态 | 网关行为 |
|
||||||
@@ -270,7 +298,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||||
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||||
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||||
| `custom:apply_patch` | ◐ | 整体剥离;grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
| `custom:apply_patch` | ◐ | 整体剥离;Grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||||
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||||
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||||
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||||
@@ -293,6 +321,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 未知模型返回 404,无可用渠道返回 503
|
- 未知模型返回 404,无可用渠道返回 503
|
||||||
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||||
|
|
||||||
|
<a id="compat-chat"></a>
|
||||||
|
|
||||||
### OpenAI Chat Completions
|
### OpenAI Chat Completions
|
||||||
|
|
||||||
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||||
@@ -348,6 +378,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`prompt_tokens_details.cached_tokens`
|
`prompt_tokens_details.cached_tokens`
|
||||||
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
<a id="compat-messages"></a>
|
||||||
|
|
||||||
### Anthropic Messages
|
### Anthropic Messages
|
||||||
|
|
||||||
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||||
@@ -401,6 +433,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||||
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||||
|
|
||||||
|
<a id="compat-embeddings"></a>
|
||||||
|
|
||||||
### OpenAI Embeddings
|
### OpenAI Embeddings
|
||||||
|
|
||||||
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||||
@@ -418,6 +452,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
<a id="compat-audio"></a>
|
||||||
|
|
||||||
### 语音生成
|
### 语音生成
|
||||||
|
|
||||||
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||||
@@ -458,6 +494,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 不提供 HTTP 流式音频或 WebSocket 代理
|
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
<a id="compat-rerank"></a>
|
||||||
|
|
||||||
### Rerank
|
### Rerank
|
||||||
|
|
||||||
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||||
@@ -475,6 +513,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||||
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||||
|
|
||||||
|
<a id="compat-moderations"></a>
|
||||||
|
|
||||||
### Moderations
|
### Moderations
|
||||||
|
|
||||||
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||||
@@ -496,6 +536,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||||
- 响应 `model` 恒为 `oci-guardrails`
|
- 响应 `model` 恒为 `oci-guardrails`
|
||||||
|
|
||||||
|
<a id="compat-models"></a>
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||||
@@ -512,7 +554,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
<a id="implementation"></a>
|
<a id="implementation"></a>
|
||||||
|
|
||||||
## 实现索引
|
## 附录:实现索引
|
||||||
|
|
||||||
| 端点 / 能力 | 主要实现 |
|
| 端点 / 能力 | 主要实现 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -526,3 +568,5 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||||
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||||
|
|
||||||
|
[返回顶部](#top)
|
||||||
|
|||||||
@@ -1572,6 +1572,12 @@ const docTemplate = `{
|
|||||||
"description": "单批目标条数,缺省 100,上限 200",
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -9439,6 +9445,9 @@ const docTemplate = `{
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scannedThrough": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1565,6 +1565,12 @@
|
|||||||
"description": "单批目标条数,缺省 100,上限 200",
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -9432,6 +9438,9 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scannedThrough": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2271,6 +2271,8 @@ definitions:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/definitions/oci-portal_internal_oci.AuditEvent'
|
$ref: '#/definitions/oci-portal_internal_oci.AuditEvent'
|
||||||
type: array
|
type: array
|
||||||
|
scannedThrough:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
oci-portal_internal_service.Changes:
|
oci-portal_internal_service.Changes:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -3637,6 +3639,10 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)
|
||||||
|
in: query
|
||||||
|
name: q
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
|
|||||||
@@ -68,13 +68,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
|||||||
// ---- 租户审计日志 ----
|
// ---- 租户审计日志 ----
|
||||||
|
|
||||||
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。
|
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
|
||||||
|
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
|
||||||
//
|
//
|
||||||
// @Summary 批式懒加载查询租户 OCI 审计事件
|
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||||
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||||
|
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||||
// @Success 200 {object} service.AuditEventsView
|
// @Success 200 {object} service.AuditEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||||
@@ -84,7 +86,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
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)
|
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||||
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
+471
-186
@@ -6,19 +6,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
// maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条,
|
||||||
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
// 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查。
|
||||||
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
|
||||||
const maxAuditPages = 10
|
const maxAuditPages = 10
|
||||||
|
|
||||||
|
// auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时
|
||||||
|
// 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求,
|
||||||
|
// 前端按已回溯位置展示进度并自动续查。
|
||||||
|
const auditBatchTimeBudget = 20 * time.Second
|
||||||
|
|
||||||
|
// auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询
|
||||||
|
// 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。
|
||||||
|
const auditSearchPageLimit = 200
|
||||||
|
|
||||||
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||||
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存,
|
||||||
// 列表响应不再携带(详情接口按 eventId 取回)。
|
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||||
type AuditEvent struct {
|
type AuditEvent struct {
|
||||||
EventId string `json:"eventId"`
|
EventId string `json:"eventId"`
|
||||||
@@ -35,7 +45,7 @@ type AuditEvent struct {
|
|||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断,
|
||||||
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||||
type AuditEventsResult struct {
|
type AuditEventsResult struct {
|
||||||
Items []AuditEvent `json:"items"`
|
Items []AuditEvent `json:"items"`
|
||||||
@@ -43,6 +53,324 @@ type AuditEventsResult struct {
|
|||||||
NextPage string `json:"nextPage,omitempty"`
|
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) {
|
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||||
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl
|
|||||||
return ac, nil
|
return ac, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
// listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定
|
||||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
// 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文,
|
||||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
// 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染。
|
||||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, 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) {
|
|
||||||
req := audit.ListEventsRequest{
|
req := audit.ListEventsRequest{
|
||||||
CompartmentId: &tenancyOCID,
|
CompartmentId: &tenancyOCID,
|
||||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
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 {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("list audit events: %w", err)
|
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)。
|
// toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||||
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。
|
|
||||||
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||||
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||||
if ev.EventTime != nil {
|
if ev.EventTime != nil {
|
||||||
@@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
|||||||
return out
|
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 时间排最后。
|
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||||
func sortAuditEvents(items []AuditEvent) {
|
func sortAuditEvents(items []AuditEvent) {
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
|||||||
+294
-27
@@ -1,14 +1,262 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"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) {
|
func TestToAuditEvent(t *testing.T) {
|
||||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{
|
want: AuditEvent{
|
||||||
EventId: "evt-abc",
|
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
|
||||||
EventTime: &eventTime,
|
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
|
||||||
EventName: "TerminateInstance",
|
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
|
||||||
Source: "ComputeApi",
|
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
|
||||||
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 各自安全跳过",
|
name: "嵌套局部 nil 各自安全跳过",
|
||||||
ev: audit.AuditEvent{
|
ev: audit.AuditEvent{
|
||||||
Data: &audit.Data{
|
Data: &audit.Data{
|
||||||
EventName: common.String("GetInstance"),
|
EventName: common.String("GetInstance"),
|
||||||
Identity: nil,
|
|
||||||
Request: &audit.Request{Path: common.String("/instances")},
|
Request: &audit.Request{Path: common.String("/instances")},
|
||||||
Response: nil,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||||
},
|
},
|
||||||
{
|
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
|
||||||
name: "空事件全部零值",
|
|
||||||
ev: audit.AuditEvent{},
|
|
||||||
want: AuditEvent{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 不参与,其余反射比较。
|
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||||
func auditEventEqual(a, b AuditEvent) bool {
|
func auditEventEqual(a, b AuditEvent) bool {
|
||||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||||
@@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
Start: now.Add(-24 * time.Hour),
|
Start: now.Add(-24 * time.Hour),
|
||||||
End: now,
|
End: now,
|
||||||
WindowHours: 24,
|
WindowHours: 24,
|
||||||
|
Q: "kw",
|
||||||
}
|
}
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||||
{"空窗倍增", base, true, 48, 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},
|
{"窗宽缺省按 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},
|
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
if next.Page != "" {
|
if next.Page != "" {
|
||||||
t.Fatalf("新窗应清空窗内游标, got %q", 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to
|
|||||||
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
||||||
|
|
||||||
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
||||||
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。
|
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数;
|
||||||
|
// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。
|
||||||
type AuditQuery struct {
|
type AuditQuery struct {
|
||||||
Region string
|
Region string
|
||||||
Cursor string
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
|
Q string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
||||||
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。
|
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头;
|
||||||
|
// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。
|
||||||
type AuditEventsView struct {
|
type AuditEventsView struct {
|
||||||
Items []oci.AuditEvent `json:"items"`
|
Items []oci.AuditEvent `json:"items"`
|
||||||
Cursor string `json:"cursor,omitempty"`
|
Cursor string `json:"cursor,omitempty"`
|
||||||
Exhausted bool `json:"exhausted"`
|
Exhausted bool `json:"exhausted"`
|
||||||
|
ScannedThrough *time.Time `json:"scannedThrough,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
||||||
@@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
}
|
}
|
||||||
|
if q.Cursor == "" {
|
||||||
|
cur.Q = oci.SanitizeAuditTerm(q.Q)
|
||||||
|
}
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
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}
|
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
||||||
if res.Cursor != nil {
|
if res.Cursor != nil {
|
||||||
view.Cursor = encodeAuditCursor(*res.Cursor)
|
view.Cursor = encodeAuditCursor(*res.Cursor)
|
||||||
|
view.ScannedThrough = &res.Cursor.End
|
||||||
}
|
}
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user