30 Commits
Author SHA1 Message Date
wangdefa 0614ef22af 发布 v0.8.0
CI / test (push) Successful in 22s
Release / release (push) Successful in 51s
2026-07-21 19:43:02 +08:00
wangdefa 33e92a65e2 成本查询限窗过滤;桶级 PAR 放开列举
CI / test (push) Successful in 35s
2026-07-21 19:26:33 +08:00
wangdefa f40f2a20e8 新增对象存储/账单/保留IP模块,删桶并发提速与成本增强
CI / test (push) Successful in 33s
2026-07-21 12:11:05 +08:00
wangdefa 9cfde8b702 发布 v0.7.3
CI / test (push) Successful in 20s
Release / release (push) Successful in 58s
2026-07-17 17:09:06 +08:00
wangdefa deea8629e0 实例规格透传 quotaNames,重新生成 swagger
CI / test (push) Successful in 31s
2026-07-17 16:52:49 +08:00
wangdefa 6cf9465fea 云端事件:修复实例通知并移出 LaunchInstance 回传 2026-07-17 16:52:49 +08:00
wangdefa 882eeade1e 修复跨区间缓存串数据与会话回收泄漏,收敛网关重试
CI / test (push) Successful in 29s
2026-07-17 12:19:21 +08:00
wangdefa 7019d4c5a6 文档:记录 multi-agent 加密推理流式断流问题 2026-07-17 12:19:21 +08:00
wangdefa 18e63d2dbd 补提 DASH_VERSION 至 v0.7.2
CI / test (push) Successful in 21s
Release / release (push) Successful in 52s
2026-07-16 21:33:38 +08:00
wangdefa 91999205e2 发布 v0.7.2
CI / test (push) Successful in 22s
Release / release (push) Successful in 48s
2026-07-16 21:26:39 +08:00
wangdefa cb66567256 responses 直通超时可配,流式去总超时,代理补阶段超时
CI / test (push) Successful in 31s
2026-07-16 21:20:02 +08:00
wangdefa d56678e1de 发布 v0.7.1
CI / test (push) Successful in 21s
Release / release (push) Successful in 40s
2026-07-16 16:42:29 +08:00
wangdefa 8897c847a1 审计改用日志搜索倒序,支持检索与无配额租户回退
CI / test (push) Successful in 31s
2026-07-16 16:36:31 +08:00
wangdefa b2252678dd 发布 v0.7.0
CI / test (push) Successful in 21s
Release / release (push) Successful in 59s
2026-07-16 12:59:52 +08:00
wangdefa 7002e42c06 美化项目与AI网关文档
CI / test (push) Successful in 21s
2026-07-16 12:47:57 +08:00
wangdefa da7b29d2e3 设置页AI Tab:网关设置入库、保险丝改维度与grok工具注入
CI / test (push) Successful in 33s
2026-07-16 12:31:36 +08:00
wangdefa 99b551401e 实例地址恒以主网卡为准,修复多网卡覆写随机 2026-07-16 12:31:36 +08:00
wangdefa 4e2bab3032 升级 oci-go-sdk 到 v65.121.0
CI / test (push) Successful in 44s
2026-07-15 14:23:01 +08:00
wangdefa e1f8a0539c AI网关:Responses直通codex兼容与流式升级回退
CI / test (push) Successful in 31s
Release / release (push) Successful in 39s
2026-07-15 10:39:56 +08:00
wangdefa a8bde89b56 更新 DASH_VERSION 至 v0.6.0
CI / test (push) Successful in 46s
Release / release (push) Successful in 43s
2026-07-14 19:33:33 +08:00
wangdefa 1da2197a6c 渠道模型列表与测试端点,修复探测无配额误判 2026-07-14 19:33:33 +08:00
wangdefa 79c9e4d9b9 更新 DASH_VERSION 至 v0.5.1
CI / test (push) Successful in 24s
Release / release (push) Successful in 55s
2026-07-14 11:04:56 +08:00
wangdefa 2fea315430 OIDC 登录/绑定回调写系统日志
CI / test (push) Successful in 30s
2026-07-14 11:00:55 +08:00
wangdefa b4ef98a25e AI网关新增xAI格式TTS端点与过滤弃用模型开关
CI / test (push) Successful in 32s
Release / release (push) Successful in 54s
- POST /ai/v1/tts:xAI 官方 TTS 格式转换层(text/voice_id→input/voice),复用 Speech 编排,model 缺省 xai.grok-tts;实测 output_format/speed 透传生效
- 「过滤弃用模型」开关(GET/PUT /api/v1/ai-settings,settings 表持久化):开启后已宣布弃用(未退役)模型从列表与路由排除;同步入库与退役提醒不受影响
- AI 网关文档更名 docs/AI网关.md,补 tts 矩阵段与开关说明;README 引用同步
- CHANGELOG 0.5.0(0.4.0 已发版冻结);DASH_VERSION v0.5.0
2026-07-14 09:49:28 +08:00
wangdefa 0a86b5a291 AI网关新增TTS/重排/审核端点,xAI工具扩展,swagger修缺
CI / test (push) Successful in 32s
Release / release (push) Successful in 1m4s
- 新端点 /ai/v1/audio/speech(xai.grok-tts)、/rerank(cohere.rerank-v4)、/moderations(OCI Guardrails)
- Responses 放行 code_interpreter 与远程 mcp 工具,web_search/x_search 解除仅非流式限制
- 模型能力映射扩展:TEXT_RERANK→RERANK、TEXT_TO_AUDIO→TTS
- AI 网关文档独立 docs/ai-gateway.md,字段兼容矩阵只列支持项;README 精简引用
- swagger 修缺:135 处响应注解具体化,RawMessage/联合类型统一渲染 AnyJSON,overrides 迁至 docs/.swaggo
- CHANGELOG 0.4.0,版本段不再记日期;DASH_VERSION v0.4.0
2026-07-13 20:17:06 +08:00
wangdefa 9309ad1ffc 修复 AI 网关流式断流:错误透传、自动降级,max_tokens 可缺省
CI / test (push) Successful in 33s
Release / release (push) Successful in 56s
2026-07-13 15:06:01 +08:00
wangdefa 81d4650f3d 代理批量关联租户接口、关于页运行时与资源指标
CI / test (push) Successful in 31s
Release / release (push) Successful in 57s
2026-07-13 12:23:18 +08:00
wangdefa c7cc5616ed 发布 0.3.0:恢复 Chat 接口并增强云端事件通知
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s
2026-07-13 10:13:44 +08:00
wangdefa 489cb49cb3 AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s
2026-07-12 17:48:28 +08:00
wangdefa 7706f59549 发布 0.2.0:模型池自愈、探测修正、任务异步触发与删除加固
CI / test (push) Successful in 32s
Release / release (push) Successful in 52s
2026-07-10 20:25:37 +08:00
148 changed files with 28227 additions and 5381 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: 下载 DASH_VERSION 固定版本的前端 dist.zip,校验后解压进嵌入目录
- name: 下载前端 dist.zip
env:
TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
@@ -36,7 +36,7 @@ jobs:
unzip -q dist.zip -d internal/webui/dist
test -f internal/webui/dist/index.html
- name: 构建双架构二进制(artifact stage 导出)
- name: 构建双架构二进制
run: |
docker buildx build --pull \
--platform linux/amd64,linux/arm64 \
+1 -1
View File
@@ -1 +1 @@
0.6.5
0.6.6
+31
View File
@@ -27,3 +27,34 @@ go func() {
- 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。
- cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前**
- 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add``Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。
## HTTP 处理器不得同步执行长任务
**问题**:`POST /tasks/:id/run` 曾在处理器内同步跑完整个任务(AI 探测挂上模型验证后单次 24~90s),前端点击后长时间零反馈;且执行入口无并发防护,双击/与 cron 重叠会真跑两次。
**约定**(task.go `TriggerTask` 为范例):
- 可能超过 1~2s 的执行,API 只做「异步触发」:校验存在 → 抢占执行权 → 挂 WaitGroup 的 goroutine 执行 → 立即 202;结果落任务日志/通知,由前端轮询呈现。
- 在飞防重用 `map[uint]bool` + 专属 mutex(`beginRun`/`endRun`):手动重复触发返回哨兵错误(API 映射 409),cron 重叠触发静默跳过。执行权的获取必须在触发方同步完成,不能移进 goroutine(否则有双触发窗口)。
- 后台执行 goroutine 一律纳入服务的 WaitGroup,`Stop()``cron.Stop()` 之后追加 `runWG.Wait()` 收尾。
## 上游 HTTP 超时:流式/长调用禁用 http.Client.Timeout 总超时
**问题**(2026-07 responses 直通 60s 断流):`http.Client.Timeout` 覆盖单次请求全生命周期(发起→响应头→**body 读完**)。OCI SDK 默认 60s(`common/client.go` defaultTimeout),对 SSE 流式(读 body 无上限)与 multi-agent/搜索类慢模型(>60s 才回响应头)必然掐断;且超时被 `switchable` 判为可重试,还会误伤渠道熔断计数。
**约定**(genai_responses.go 为范例):
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
**问题**:每个 OCI 操作都新建 SDK client,代理路径连带每次 new 一个 `http.Transport`——连接零复用,每请求付整条 TCP+SOCKS5+TLS 握手(经代理 3+ RTT ≈ 1s+);且对象存储每操作先远程取一次 namespace,单条 PAR 删除被放大到 ~2.3s,几千条串行删除撑爆 30 分钟 purge 窗口。用完即弃的 Transport 未设 `IdleConnTimeout`(零值=客户端永不关),空闲连接堆到远端 ~65s 超时才断,代理侧稳态挂着几十条连接。
**约定**:
- 代理出站 `http.Client` 一律经 proxyhttp.go 的包级 `proxyClients` 缓存(按 ProxySpec 值复用),不得在调用点新建 per-request Transport;共享实例只可包装(dispatcher wrap),不得改写其字段。连接池参数集中在 `pooledTransport()`:`MaxIdleConnsPerHost` 须 ≥ 批量并发数。
- 租户常量(对象存储 namespace 等)在 `RealClient``sync.Map` 进程内缓存(照 `limitDefs`/`shapes` 模式),不逐操作远程取。
- 批量逐条调用统一走 service 层 `forEachConcurrently` + `bulkDeleteWorkers`(16);对应的 SDK 删除请求带 `bulkRetryPolicy`(429/5xx 退避),并发提速与限流保护成对出现,二者缺一不可。
+38 -1
View File
@@ -24,7 +24,20 @@ Questions to answer:
<!-- How should queries be written? Batch operations? -->
(To be filled by the team)
### 租户级数据删除
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
### 大集合谓词用子查询,禁止展开 IN 列表
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
---
@@ -48,6 +61,14 @@ Questions to answer:
<!-- Database-related mistakes your team has made -->
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
**Cause**:GORM `Save``UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
@@ -66,3 +87,19 @@ db.Model(&model.AiChannel{}).
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
```
### `serializer:json` 字段走 map Updates 时手动 marshal
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
-`Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
### Common Mistake: 进程内缓存键漏掉查询维度
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
**Cause**:`internal/oci/cached.go``ckey` 只拼了租户 OCID+资源名+region,而底层查询按 `cred.EffectiveCompartment()` 过滤——影响结果的维度没有全部进键,不同参数命中同一条缓存。
**Fix**:键值加入 `cred.CompartmentID`(空 = 租户根,天然区分)。
**Prevention**:缓存键必须覆盖影响回源结果的**全部**输入维度(租户、区间、区域、过滤参数);给 Credentials/查询结构体新增会改变结果的字段时,同步检查 `ckey` 调用点;隔离行为写进 `cached_test.go` 的 isolation 用例。
+3 -1
View File
@@ -1,6 +1,6 @@
# 后端目录结构
> Go 后端工程的目录职责与代码组织约定。
> Go 后端工程(`oci-portal/`)的目录职责与代码组织约定。
## 目录职责
@@ -30,3 +30,5 @@ oci-portal/
- `defer` 紧跟资源获取语句,成对管理释放。
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
- 路由参数:资源 id 可能含 `/` 等 URL 保留字符时(如 OCI PAR id),一律经 query 传递而非路径参数 —— gin 路径段不匹配 `%2F`,会直接 404(2026-07 round6 教训)。
- OSP Gateway(账单/发票,`internal/oci/billing.go`):服务只在租户主区域提供,每个请求都带 `ospHomeRegion` 且客户端 `SetRegion` 到主区域 —— 主区域公共名经测活 `HomeRegionKey` + `RegionByKey` 解析,勿用凭据 region 直连;SDK 金额是 `*float32`,输出前经 `money()` 四舍五入抹掉 float64 转换噪音;`PayInvoice``Email` 是 API 必填(付款回执),service 层校验后透传。
+10
View File
@@ -31,3 +31,13 @@ func sanitizeURLError(err error) error {
```
**预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。
## OCI 错误按语义分类,不要只看 HTTP 状态码
**问题**:OCI 同一状态码承载多种语义,按状态码一刀切会误判。已踩过的案例(GenAI 网关):
- **404 双义**:`NotAuthorizedOrNotFound`(消息 "Authorization failed or requested resource not found")是**租户级**无权限/无策略;"Entity with key … not found" 是**模型级**——该模型 OCID 在此区域无按需供给。前者应定论渠道无配额,后者应剔除该模型换下一个候选。
- **400 微调基座**:"Not allowed to call finetune base model …, use Endpoint: false" 表示模型在该区域仅作微调基座(dedicated 供给),同样是模型级不可用,不是请求参数错误。
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As``common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
+4 -3
View File
@@ -1,6 +1,6 @@
# Backend Development Guidelines(oci-portal 后端规范)
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。迁自 docs/开发指南.md 与根目录 AGENTS.md(2026-07)。
> Go 后端(`oci-portal/`)编码规范入口。
---
@@ -19,7 +19,8 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
| [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) | 结构化日志、日志级别 | 待填 |
---
@@ -51,7 +52,7 @@ go mod tidy # 依赖有变更时
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
```bash
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
```
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
+19
View File
@@ -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` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
@@ -16,3 +16,14 @@
- Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()`
- 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`
- 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。
## JSON 联合类型与内容留档保真(2026-07 内容日志失真教训)
- aiwire 里兼容多形态的联合类型(如 `RespInput` string|数组)**必须成对实现**
`UnmarshalJSON``MarshalJSON`:只写 Unmarshal 时,任何再序列化(内容日志、
测试快照)都会退化成 Go 字段名形态(`{"Text":"","Items":[...],"IsArray":true}`),
排障者复制它复现会直接 400。参考 `aiwire/openai.go Content` 的保形写法,
往返用 table-driven 测试锁定(`aiwire/responses_test.go`)。
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal`
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
+13 -13
View File
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
- 1.2 Research `[optional · repeatable]`
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
- 1.5 Completion criteria
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
- Planning or unclear requirements -> `trellis-brainstorm`.
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[codex-inline, Kilo, Antigravity, Devin]
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
Spawn the research sub-agent:
@@ -361,7 +361,7 @@ Spawn the research sub-agent:
- **Task description**: Research <specific question>
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[codex-inline, Kilo, Antigravity, Devin]
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
#### 1.3 Configure context `[required · once]`
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
Skip this step only when both files already have real curated entries.
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[codex-inline, Kilo, Antigravity, Devin]
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
| `design.md` exists (complex tasks) | ✅ |
| `implement.md` exists (complex tasks) | ✅ |
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
---
@@ -472,7 +472,7 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
#### 2.1 Implement `[required · repeatable]`
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
Spawn the implement sub-agent:
@@ -484,7 +484,7 @@ The platform hook/plugin auto-handles:
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
@@ -526,7 +526,7 @@ The platform prelude auto-handles the context load requirement:
#### 2.2 Quality check `[required · repeatable]`
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
Spawn the check sub-agent:
@@ -540,7 +540,7 @@ The check agent's job:
- Auto-fix issues it finds
- Run lint and typecheck to verify
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[codex-inline, Kilo, Antigravity, Devin]
+203 -3
View File
@@ -1,8 +1,208 @@
# Changelog
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.1.0] - 2026-07-10
## [0.8.0]
### Added
- 新增对象存储管理:支持按区域 / 区间创建存储桶(可选可见性、存储层和版本控制),后续可更新可见性 / 版本控制;对象支持按虚拟目录分页浏览、删除、重命名、查看元数据和取回 Archive,非空桶可转后台清空全部对象版本与 PAR 后删除
- 新增对象内容中转接口:小文件预览与编辑不再签发 PAR,读取上限 20 MB、写入上限 5 MB,保存支持 `If-Match` 并发保护;上传、下载和分享继续使用 PAR 直连
- 新增对象存储 PAR 管理:支持对象级只读 / 只写 / 读写链接和桶级读写(`AnyObjectReadWrite`)链接、自定义有效期、游标分页、单条 / 全部删除;桶级链接允许列举对象
- 新增账单管理(OSP Gateway,仅主区域):按自然年查询发票、查看费用明细、下载 PDF、查看付款方式,并可使用默认付款方式提交在线支付
- 新增保留公网 IP 管理:支持按区间列出、创建、绑定 / 解绑和删除;创建实例或附加 VNIC 时可在资源就绪后自动绑定,并新增指定 VNIC 更换临时公网 IP
### Changed
- 成本 / 用量查询新增 `HOURLY` 粒度和双维度分组(如 `service,skuName`),响应通过 `subValue` 返回第二维,支持「今天」小时视图与服务下的 SKU 明细
- 主网卡或次要 VNIC 更换公网 IP 时,如原地址为保留 IP,改为自动解绑并保留该地址,再分配新的临时 IP(此前会拒绝操作)
- 对象存储 namespace 与代理出站客户端改为进程内复用,对象版本 / PAR 清理由固定并发执行,显著降低大量对象或分享链接场景下的删桶耗时与连接开销
### Fixed
- 修复 `HOURLY` 成本查询因 OCI Usage API 将起点下扩到 UTC 当日而夹带窗口外数据;服务端现严格按 `[startTime, endTime)` 过滤返回行
- 修复 VCN 级联删除遗漏网络安全组(NSG),导致其他资源已清理后仍因关联关系返回 409
- 修复区间缓存未保存 / 返回 `parentId`,导致多层区间在前端被平铺;旧缓存检测到层级缺失时会自动实时刷新并回写
## [0.7.3]
### Changed
- `LaunchInstance` 移出回传关键事件清单(Connector 过滤条件与「云端事件」告警白名单共用):创建以面板自身(手动 / 抢机反复尝试)发起为主,回传即噪声(高频抢机可在一两天内刷掉 2 万条存量上限);终止与电源操作等外部风险信号保留。链路幂等创建新增过滤条件对账,存量链路在租户详情点「一键创建」即原地更新条件,无需拆除重建
- 实例规格清单(ListShapes)透传 OCI `quotaNames`(与 compute limits 配额名同名),供前端数据驱动配额与可用域可用性判定
### Fixed
- 修复「云端事件」实例生命周期通知从未生效:Audit v2 计算类事件类型带 `.begin` / `.end` 阶段后缀(如 `…ComputeApi.LaunchInstance.end`),事件短名取末段得到 `begin` / `end`,不在关键事件集合内而被跳过。现剥离阶段后缀判定,成对事件只推 `.begin`(携带操作者 / IP / 成败;`.end` 无操作者且信息重复);`X failed with response 'Err'` 形态消息判为失败并提取引号内错误码作告警补充说明;告警资源名在 `resourceName` 缺失时回退外层 `source`(实例名)
## [0.7.2]
### Added
- AI 网关设置新增「上游无响应预算」(`upstreamWaitSeconds`30..900 秒,缺省 300,持久化、即时生效):非流式为单次尝试总超时,流式为等待响应头上限,供 multi-agent / 搜索类慢模型调宽
### Changed
- 出站代理 Transport 补齐阶段超时(连接 30 秒 / TLS 握手 10 秒,对齐 SDK 直连模板):连不上的代理快速失败,不再拖满总超时
### Fixed
- 修复 Responses 直通调用 multi-agent / 搜索类模型必然超时:SDK 默认 `http.Client` 60 秒总超时覆盖到 body 读完,流式恰好 60 秒断流、非流式(响应头 >60 秒才返回)重试耗尽后约 121 秒报错。非流式改用预算总超时;流式去掉总超时,以定时取消模拟等待响应头预算,响应头到达后流时长不限、生命周期由客户端连接决定。附带消除此类超时对渠道熔断计数的误伤
## [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
- Responses 直通 codex 兼容层,codex CLI`wire_api = "responses"`)指向网关即用,实测 codex-cli 0.144.1 主会话与 multi-agent 子代理全链路可用:
- `namespace` 工具组(承载 codex multi-agent 与全部 MCP 工具,仅 OpenAI 原生后端识别,OCI 实测 422)拍平为限定名 `function` 工具上送(`ns__child``mcp__` 开头子工具名不加前缀),响应中命中的 `function_call` 还原为短名并补回 `namespace` 字段(codex 以该双字段路由);多轮历史 `function_call` 与对象形态 `tool_choice` 上的 `namespace` 字段自动重限定
- `custom` 自由格式工具(apply_patch 及 GPT-5.x 档位子代理形态,OCI 不识别)转换为带 `{"input": string}` 包装 schema 的 `function`,响应回转 `custom_tool_call` 并解包 `input`,多轮历史的 `custom_tool_call(_output)` 逆向转换,往返无损;例外:`apply_patch` 整体剥离(grok 系未训练 codex 补丁格式,模型自然回落 shell 编辑)
- `tool_search`(codex 工具目录搜索)剥离:namespace 已全量拍平上送,搜索语义冗余且上游不识别
- `web_search` 的 OpenAI 专有参数 `external_web_access`OCI 实测 400):`true` 仅删键放行(等价上游默认行为),`false` 为"仅缓存检索"降权模式,按不越权原则连工具剥离
- 以上改写动作(拍平 / 转换 / 剥离)与校验拒绝均记服务端日志可观测
- Responses 直通流式升级回退:实测 OCI 上游对超过约 82KB 的流式请求会在推理阶段掐断流(纯请求体积触发,与工具构成无关,非流式不受影响),请求体超 76KB 时自动改调非流式上游并合成最小 SSE 事件序列(`created` → 逐项 `output_item.done``completed`)返回,语义完整仅丢失增量输出——0.3.1 中「Responses 直通无法透明降级」的限制自此按体积预判解除
### Changed
- `/ai/v1/responses` 工具类型白名单扩展:`namespace` / `custom` / `tool_search` 不再 400 拒绝,按上述兼容策略处理后转发;其余未知工具类型维持请求前置拒绝
## [0.6.0]
### Added
- 渠道模型缓存列表端点 `GET /api/v1/ai-channels/{id}/models`:黑名单模型查询层兜底排除,「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)
- 单模型测试端点 `POST /api/v1/ai-channels/{id}/test-model`:对指定模型发 max_tokens=16 试调,通过即写入渠道「探测验证模型」(此后手动探测与每日后台任务将其置于试调候选首位),渠道探测状态不为可用时顺带置可用并复位熔断;未通过如实返回上游错误且不改动渠道状态
- 渠道列表响应回填 `modelCount`(模型缓存计数,与模型列表同口径:排除黑名单、随过滤弃用开关)
### Fixed
- 修复渠道探测「无配额」误判:配额试调遇 401/403/鉴权 404 不再立即定论租户无配额(可能仅个别模型无权限),改为继续尝试其余候选,任一成功即判可用,全部失败且出现过鉴权拒绝才判无配额
- 探测试调 `max_output_tokens` 由 1 提升到 16openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判
## [0.5.1]
### Fixed
- 修复外部身份(OAuth2)登录 / 绑定无系统日志的问题:回调成功记 200(用户名为面板账号)、失败记 401(附失败原因);此前回调为 GET 请求不经写操作日志中间件,完全无记录
## [0.5.0]
### Added
- xAI 官方格式文本转语音端点 `POST /ai/v1/tts`:接受 xAI 官方 TTS 请求(`text` / `language` 必填,`voice_id``output_format`{codec, sample_rate, bit_rate}、`speed` 等),网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与渠道调度;`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format` 对象与 `speed` 透传生效,xAI SDK / 客户端可直接指向网关
- 「过滤弃用模型」开关(`GET` / `PUT /api/v1/ai-settings`,持久化):开启后 OCI 已宣布弃用(即使未到退役日)的模型从模型列表与路由中同时排除,关闭恢复;渠道同步入库与 30 天退役提醒不受影响
### Changed
- AI 网关文档更名为 `docs/AI网关.md`(原 `docs/ai-gateway.md`),README 引用同步
## [0.4.0]
### Added
- 文本转语音端点 `POST /ai/v1/audio/speech`OpenAI Audio Speech 兼容,直通 OCI 兼容面(模型 `xai.grok-tts`voice 取 xAI Grok Voice 列表 `ara`/`eve`/`leo`/`rex`/`sal`);上游必填的 `language` 缺省时自动注入 `"auto"`,xAI 专属参数平铺透传;响应为一次性完整音频(Content-Type 透传上游,缺省 audio/mpeg),不提供流式
- 文档重排端点 `POST /ai/v1/rerank`Jina / Cohere 通行协议,走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),支持 `top_n``return_documents``results[].index` 指向入参 `documents` 下标
- 内容审核端点 `POST /ai/v1/moderations`OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入),`input` 为字符串或字符串数组(单次至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(任一得分 ≥0.5 判 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged;实测中文人名 / 手机号识别较弱,英文正常)
- Responses 服务端工具扩展:放行 Oracle 文档化的 xAI `code_interpreter` 与远程 `mcp` 工具,并解除 `web_search` / `x_search` 仅非流式的限制——四类服务端工具非流式与流式均实测可用;`code_interpreter` 的命名容器管理与 File Search 不提供
- 模型能力映射扩展:`TEXT_RERANK` → RERANK、`TEXT_TO_AUDIO` → TTS,渠道探测 / 同步自动发现重排与语音模型入池
### Changed
- AI 网关文档独立成篇 `docs/ai-gateway.md`(端点定位、兼容边界、已知上游限制、字段兼容矩阵——矩阵只列支持项,不支持 / 忽略项以文字简述),README 精简为概览并引用;实测披露:Responses `input_file` 内容块被上游以 ZDR 形态拒绝,能力不可用
- swagger 全面修缺:135 处响应注解从泛型 map 改为具体类型(补文档用途响应结构与泛型列表外壳),AI 网关端点请求 / 响应 schema 完整可见;`json.RawMessage` 与联合类型统一渲染为任意 JSON 值(AnyJSON),不再误显示为 byte 数组;swag overrides 配置移至 `docs/.swaggo`,生成命令加 `--overridesFile docs/.swaggo`;顺带修正网页控制台会话创建响应码(200→201)与 SAML 元数据下载(改文件响应)两处注解失真
## [0.3.1]
### Added
- AI 网关流式断流自动降级:Messages 与 Chat Completions 流式请求在客户端尚未收到任何输出时遭遇上游断流,自动降级为非流式重做,结果按标准事件 / chunk 序列一次推送,调用日志记 `retries=1` 与降级标记。实测 OCI 兼容面对 `instructions``tools` 合计超约 64.5KB 的流式请求会静默断连(无错误事件,非流式正常,消息正文不计入),Claude Code 等大体量系统提示客户端极易触发;Responses 直通因初始事件已转发无法透明降级,日志记「上游流提前终止」;README 增补「已知上游限制:大 system 区流式断流」小节
### Changed
- Messages 的 `max_tokens` 改为可缺省:缺省或 ≤0 时按默认值 8192 放行(此前返回 400;部分客户端将该字段视为选填)
### Fixed
- AI 网关流式假成功:上游 error / `response.failed` 事件此前被吞掉,流提前 EOF 也被伪装成正常结束,调用日志呈现 200 · 0/0 且无错误信息;现 Messages 将上游失败转为 Anthropic `error` 事件,三个流式端点日志均记录上游错误消息或「上游流提前终止,未返回终态事件」
## [0.3.0]
### Added
- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志
- 代理侧批量关联租户接口 `PUT /api/v1/proxies/{id}/tenants`:一次调用设置关联该代理的租户全集——列表内租户改挂到本代理(含从其他代理改挂),列表外已关联的解除,事务生效并返回最新关联计数;含无效租户 ID 返回 400,代理不存在返回 404
- `/api/v1/about` 扩展运行时信息:进程启动时刻 `startedAt`、运行秒数 `uptimeSeconds` 与资源占用 `resources`——CPU 自启动均值(占单核百分比)、CPU 核数、goroutine 数、Go 堆内存与向系统申请内存、数据库引擎与磁盘占用(SQLite 统计主文件与 -wal / -shmMySQL / PostgreSQL 外部库返回 -1 表示不可度量)
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
### Changed
- 云端关键事件通知补充操作者、来源 IP、执行结果和说明信息;支持从 OCI Audit 与 IDCS 登录事件提取成功 / 失败状态、策略描述和登录失败原因,缺失字段统一显示占位符
### Removed
- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules``/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行
## [0.2.0]
### Added
- AI 网关模型黑名单:面板集中拉黑模型,全渠道剔除(模型列表 / 路由 / 探测候选不再出现,同步不入库),移除条目并重新同步后恢复;新表 `ai_model_blacklists`(启动自动迁移),管理接口 `/api/v1/ai-blacklist`
- AI 密钥模型白名单:密钥可限定放行的模型列表(空 = 不限),白名单外调用 404,模型列表只返回交集
- 推理力度(effort)直通:Responses `reasoning.effort` 与 Anthropic Messages `output_config.effort` 原样透传上游、不做档位校验;grok-4.3 五档(`none` 完全关闭思考)、gpt-oss `minimal`-`high`、multi-agent `xhigh`(控制并行 agent 数)实测生效
- xAI Grok 服务端工具:`/ai/v1/responses``tools` 支持 `{"type":"web_search"}` / `{"type":"x_search"}`(官方 Agent Tools 格式,可与 `function` 混用,仅非流式),响应保留 `web_search_call` 输出项与引用
### Changed
- **AI 网关对话链路整体切换到 OCI OpenAI 兼容面**`/actions/v1/responses`),typed chat 面全链路移除:`/ai/v1/responses` 请求体原样直通(仅强制 `store:false`),流式为上游原生事件流,gpt-oss 推理增量直达客户端;`/ai/v1/messages` 转换为 Responses 请求直通,流式经事件桥转回标准 Anthropic 事件序列,`stop_sequences` / `top_k` 等无兼容面对应物的字段改为静默忽略,思考输出不转 `thinking` 块;渠道探测改用兼容面最小请求;流式建立后绑定渠道,中断不重试不计熔断
- 后台任务「立即执行」改异步触发:接口立即返回 202,执行结果经任务日志轮询呈现;同一任务在途时重复触发返回 409「任务正在执行中」,与 cron 重叠触发静默跳过(此前同步阻塞数十秒且同一任务可并发重复执行)
- 渠道探测候选跨厂商分散(上限 8,voice 等不可对话形态排除):单一厂商在区域内全为微调基座时不再拖垮整个渠道的探测结论
- 网关调用遇「模型不可按需调用」类错误(微调基座 400 / 实体不存在 404)自动换渠道重试且不计入渠道熔断,错误信息带模型名便于加入黑名单;仅鉴权类错误才判定租户无配额
- 同名模型多条目去重优先保留非微调基座条目,降低缓存到不可调用 OCID 的概率
### Removed
- **`/ai/v1/chat/completions` 端点移除**(404):OpenAI 官方新能力已集中到 Responses 且本面板真实流量为零,请迁移 `/ai/v1/responses``/ai/v1/messages`
- google / cohere 对话模型不再供给(OCI OpenAI 兼容面不承接,上游 400cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
### Fixed
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
## [0.1.0]
### Added
@@ -30,7 +230,7 @@
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
## [0.0.1] - 2026-07-09
## [0.0.1]
首个版本:自托管 OCI 多租户管理面板后端。
+1 -1
View File
@@ -1 +1 @@
v0.1.0
v0.8.0
+223 -93
View File
@@ -4,83 +4,185 @@
# OCI Portal
**Oracle Cloud Infrastructure 多租户管理面板**
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-blue)
![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white)
[![Release](https://img.shields.io/github/v/release/wangdefaa/oci-portal?display_name=tag)](https://github.com/wangdefaa/oci-portal/releases/latest)
[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8?logo=go&logoColor=white)](go.mod)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white)](docker-compose.yml)
[![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE)
本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件)
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
</div>
OCI Portal 将多份 OCI API Key、云资源、成本与账单、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
## 界面预览
| 总览 | 登录 |
<p align="center">
<img src="docs/assets/screenshot-overview.png" width="960" alt="OCI Portal 总览">
</p>
<details>
<summary><strong>展开更多界面截图</strong></summary>
| 登录 | 租户 |
| --- | --- |
| ![总览](docs/assets/screenshot-overview.png) | ![登录](docs/assets/screenshot-signin.png) |
| ![登录](docs/assets/screenshot-signin.png) | ![租户](docs/assets/screenshot-tenants.png) |
| 租户 | 任务 |
| 任务 | AI 网关 |
| --- | --- |
| ![租户](docs/assets/screenshot-tenants.png) | ![任务](docs/assets/screenshot-tasks.png) |
| ![任务](docs/assets/screenshot-tasks.png) | ![AI 网关](docs/assets/screenshot-ai-gateway.png) |
| AI 网关 | 通知设置 |
| 通知设置 |
| --- |
| ![通知设置](docs/assets/screenshot-settings-notify.png) |
</details>
## 核心能力
| 能力域 | 覆盖范围 |
| --- | --- |
| ![AI 网关](docs/assets/screenshot-ai-gateway.png) | ![通知设置](docs/assets/screenshot-settings-notify.png) |
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象、在线预览编辑、PAR 直传分享)与限额查询 |
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTPAES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*``/ai/*` 实时请求 |
## 特性
## 运行形态
- **多租户管理**:多份 OCI API Key 配置集中管理,私钥/口令 AES-256-GCM 加密落库;分组、批量测活、账户类型与订阅信息识别
- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额与成本查询,多区域/多区间支持
- **抢机任务**:cron 周期尝试创建实例直到成功,支持熔断与通知
- **网页控制台**:实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道
- **AI 网关**OpenAI / Anthropic 兼容端点转发 OCI GenAI(对话/Responses/Embeddings),号池渠道加权负载均衡、熔断探测、密钥管理与用量日志
- **日志回传**OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路;自定义告警规则(事件类型/来源 IP 白名单/资源/频率阈值)命中即推送
- **租户治理**IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人;多 Identity Domain 租户可按域切换管理
- **安全**JWT + bcrypt(凭据变更旧令牌立即失效,可一键撤销全部会话)、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、请求体/超时防护、系统操作审计
- **通知**:模板化推送,五渠道并存(Telegram / Webhook / ntfy / Bark / SMTP),Webhook 可对接飞书、钉钉、Slack、企业微信机器人
```text
浏览器 / API 客户端
┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐
│ Vue 3 静态资源(go:embed
│ /api/v1 → Gin → Service → OCI SDK │
│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │
│ GORM → SQLite(默认)/ MySQL / PostgreSQLexperimental
└─────────────────────────────────────────────────────────────────┘
```
> [!NOTE]
> 默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental
> 不应据此推断服务支持多副本并发运行。
## 快速开始
### Docker Compose(推荐)
前置条件:Docker、Docker Compose v2、OpenSSL。
> [!CAUTION]
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
1. 克隆仓库并生成一份需要长期保存的 `.env`
```bash
git clone https://github.com/wangdefaa/oci-portal.git
cd oci-portal
umask 077
printf 'DATA_KEY=%s\nJWT_SECRET=%s\nADMIN_PASSWORD=%s\n' \
"$(openssl rand -hex 32)" \
"$(openssl rand -hex 32)" \
"$(openssl rand -base64 24)" > .env
chmod 600 .env
```
2. 准备数据目录:
```bash
mkdir -p data
```
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
Docker Desktop 用户通常不需要执行:
```bash
sudo chown 65532:65532 data
```
3. 启动并检查状态:
```bash
docker compose up -d
docker compose ps
```
4. 查看初始管理员密码并登录:
```bash
grep '^ADMIN_PASSWORD=' .env
```
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
启动异常时查看最近日志:
```bash
docker compose logs --tail=100 oci-portal
```
> [!TIP]
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
### 二进制运行
Release 下载对应架构的二进制后
Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64 主机将文件名中的 `amd64` 替换为 `arm64`
```bash
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> ./oci-portal-server
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
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。
set -a
. ./.env
set +a
./oci-portal-server-linux-amd64
```
访问 `http://localhost:8080`,用 `admin` 与初始密码登录
默认访问地址为 `http://localhost:8080`。`ADMIN_PASSWORD` 只在数据库中没有用户时创建初始管理员,后续启动不会用它重置密码
### Docker Compose
### 源码构建
源码构建要求 Go 1.26.5、`curl`、`unzip` 和 `sha256sum`。仓库只保留前端占位页,编译完整单文件前应下载 `DASH_VERSION` 指定的前端产物并校验:
```bash
# 镜像以 distroless nonroot(uid 65532)运行,数据卷需可写,否则 SQLite 报 unable to open database file
mkdir -p ./data && sudo chown 65532:65532 ./data
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d
DASH_TAG="$(tr -d '\r\n' < DASH_VERSION)"
DASH_BASE="https://github.com/wangdefaa/oci-portal-dash/releases/download/${DASH_TAG}"
curl -fL -o dist.zip "${DASH_BASE}/dist.zip"
curl -fL -o dist.zip.sha256 "${DASH_BASE}/dist.zip.sha256"
sha256sum -c dist.zip.sha256
rm -rf internal/webui/dist
mkdir -p internal/webui/dist
unzip -q dist.zip -d internal/webui/dist
CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
```
默认只监听 `127.0.0.1:18888`;公网访问须置于 TLS 反向代理之后,见下文「反向代理」
本地构建显示 `dev` 版本;Release 工作流会注入正式版本和构建时间。`internal/webui/dist` 中的真实前端产物不应提交到仓库
### 源码构建(单文件,含前端)
## 生产部署
```bash
# 1. 获取前端产物:从前端仓库 Release 下载 dist.zip(或本地 npm run build 后拷入)
curl -fL -o dist.zip https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip
rm -rf internal/webui/dist && mkdir -p internal/webui/dist && unzip -q dist.zip -d internal/webui/dist
# 2. 编译(免 CGO,可交叉编译;-X 两项注入「设置·关于」页的版本与构建时间,可省略,省略则显示 dev)
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X oci-portal/internal/api.buildVersion=v0.0.1 -X oci-portal/internal/api.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o bin/oci-portal-server ./cmd/server
```
> [!WARNING]
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。
### 反向代理(公网部署必读)
面板自身只提供 HTTP,管理员口令、JWT 与租户 API 私钥都会经明文承载。除本机试用外,应让面板仅监听回环地址(compose 示例已默认 `127.0.0.1:18888`),由支持 TLS 的反向代理对外提供 HTTPS。
Caddy 最小示例(整站反代,自动申请并续期 Let's Encrypt 证书,WebSocket 自动透传):
### Caddy
```caddyfile
portal.example.com {
@@ -88,10 +190,18 @@ portal.example.com {
}
```
nginx 示例(证书自备;Web Console 的 WebSocket 升级与长超时必须显式配置,否则串行终端连不上或空闲即断)
Caddy 会自动处理 WebSocket。使用 nginx、Traefik 或其他反向代理时,还需要满足
- 为串行终端和 VNC 转发 WebSocket Upgrade 头
- 为 AI 流式响应和控制台连接设置较长的读写超时,建议 `1h`
- 整站请求体上限至少为 `10MB`;后端会继续限制面板 API 为 `1MB`、AI 网关为 `10MB`
- 将面板内「设置 → 安全 → 真实 IP 请求头」配置为反向代理实际写入的头;系统审计、登录锁定和 IP 限速依赖它
- 配置 `PUBLIC_URL` 或面板地址,供 OAuth 回调和 OCI 日志回传链路使用
<details>
<summary>nginx 最小示例</summary>
```nginx
# http 块内:按请求是否升级为 WebSocket 决定 Connection 头
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
@@ -104,7 +214,6 @@ server {
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
# 放行到最大入口(AI 网关 10MB);各入口更细的上限由后端自身执行
client_max_body_size 10m;
location / {
@@ -114,69 +223,90 @@ server {
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket 空闲与 AI 流式响应都需要长读超时(默认 60s 会断)
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
```
Traefik 示例(已运行 Traefik 的 Docker 环境,面板容器加 label 接入即可,WebSocket 透明支持):
</details>
```yaml
services:
oci-portal:
image: ghcr.io/wangdefaa/oci-portal:latest
# ……环境变量与数据卷同上文 compose 示例;与 Traefik 同一 docker 网络,
# Traefik 经容器网络直达 8080,无需(也不应)对外映射 ports
labels:
- traefik.enable=true
- traefik.http.routers.oci-portal.rule=Host(`portal.example.com`)
- traefik.http.routers.oci-portal.entrypoints=websecure
- traefik.http.routers.oci-portal.tls.certresolver=le # 换成你的 certResolver 名
- traefik.http.services.oci-portal.loadbalancer.server.port=8080
```
若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。
若前端静态文件由反代直接伺服(不走内嵌页面),则按前缀代理,缺一不可:
## AI 网关
| 前缀 | 内容 | 何时需要 |
面板内置 OpenAI / Anthropic 兼容的 GenAI 网关:独立密钥鉴权(`Authorization: Bearer sk-...` 或 `x-api-key`),支持渠道分组、加权路由、熔断探测、模型黑白名单、内容日志与调用日志。
| 端点 | 定位 | 流式 |
| --- | --- | --- |
| `/api/*` | 面板 REST、Web Console WebSocket、日志回传 webhook | 始终 |
| `/ai/*` | AI 网关(OpenAI / Claude 兼容端点,独立密钥鉴权) | 启用 AI 网关时 |
| 其余路径 | 前端 SPA 静态文件(404 回退 `index.html` | 静态分离形态 |
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口(xAI 服务端工具 / MCP) | SSE |
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
| `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice | 否 |
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank | 否 |
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails | 否 |
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
- 反代默认追加的 `X-Forwarded-For` 用于还原真实客户端 IP,系统日志留痕、登录锁定与 IP 限速都依赖它
- 请求体上限建议与后端一致:`/api/*` 1MB、`/ai/*` 10MBCaddy 用 `request_body` 按前缀分层)
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
## 环境变量
## API 与配置
| 变量 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `DATA_KEY` | 是 | | 敏感字段加密主密钥(更换后已入库密文无法解密) |
| `JWT_SECRET` | 是 | 无 | 登录令牌签名密钥 |
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 首次启动是 | 无 | 仅在用户不存在时创建;已存在不重置 |
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`(后两者 experimental |
| `DB_DSN` | 外部库时是 | 无 | MySQL 需 `parseTime=True`PostgreSQL 标准 DSN |
| `DB_PATH` | 否 | `oci-portal.db` | SQLite 文件路径 |
| `PUBLIC_URL` | 否 | 无 | 面板公网基址,日志回传一键创建链路用 |
| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) |
| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) |
| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) |
| `GIN_MODE` | 否 | `release` | `debug` / `release`,影响日志与调试输出 |
### API 文档
| 路径 | 鉴权 | 用途 |
| --- | --- | --- |
| `/api/v1/*` | 登录返回的 JWT Bearer Token | 面板管理 API |
| `/ai/v1/*` | AI 网关密钥 | OpenAI / Anthropic 兼容接口 |
| `/api/v1/webhooks/oci-logs/:secret` | URL 中的独立回传密钥 | OCI Notifications 日志回传 |
OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`docs/swagger.json`](docs/swagger.json)。
运行进程时设置 `SWAGGER=1` 可开放 `/swagger/index.html`。Swagger 默认关闭,生产环境建议仅在受控网络内按需开启。接口注释变更后必须重新生成 OpenAPI。
### 环境变量
| 变量 | 使用条件 | 默认值 | 说明 |
| --- | :---: | --- | --- |
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
| `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` |
## 升级与备份
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 配置最小权限。
## 开发
```bash
go test ./... # 全量测试
go vet ./... && gofmt -l .
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency # 接口注释变更后重新生成 OpenAPI
gofmt -l .
go vet ./...
go test ./...
# Handler 注释变更后重新生成唯一的对外 API 文档。
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
```
API 文档:全部接口带 swaggo 注释,`SWAGGER=1` 启动后访问 `/swagger/index.html`spec 文件在 `docs/swagger.json|yaml`
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/)
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
## License
+4 -1
View File
@@ -110,6 +110,7 @@ func run() error {
}
ociClient := oci.NewCachedClient(oci.NewClient())
ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
defer ociConfigs.Stop()
settings := service.NewSettingService(db, cipher)
settings.SetEnvPublicURL(cfg.PublicURL)
if err := settings.ReloadSecurity(context.Background()); err != nil {
@@ -135,7 +136,7 @@ func run() error {
defer aiGateway.Wait()
defer stopCleanup()
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
ociConfigs.SetTenantCleanupDeps(tasks, logEvents)
ociConfigs.SetTenantCleanupDeps(tasks)
tasks.AttachAiGateway(aiGateway)
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
if err := tasks.Start(); err != nil {
@@ -147,6 +148,8 @@ func run() error {
console := service.NewConsoleService(ociConfigs)
proxies := service.NewProxyService(db, cipher)
defer proxies.Wait()
// 「关于」页存储指标需知数据库形态
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
return serveHTTP(cfg.Addr, router)
}
+6
View File
@@ -0,0 +1,6 @@
// swag 全局类型替换:原始 JSON 与联合类型统一渲染为 AnyJSON(任意 JSON 值),
// 避免 json.RawMessage 被误渲染成 byte 数组、联合类型暴露内部字段。
replace encoding/json.RawMessage oci-portal/internal/aiwire.AnyJSON
replace oci-portal/internal/aiwire.RespInput oci-portal/internal/aiwire.AnyJSON
replace oci-portal/internal/aiwire.Content oci-portal/internal/aiwire.AnyJSON
replace oci-portal/internal/aiwire.StringList oci-portal/internal/aiwire.AnyJSON
+597
View File
@@ -0,0 +1,597 @@
<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 兼容接口**
![OpenAI](https://img.shields.io/badge/OpenAI-Responses%20%7C%20Chat-412991?logo=openai&logoColor=white)
![Anthropic](https://img.shields.io/badge/Anthropic-Messages-D97757?logo=anthropic&logoColor=white)
![xAI](https://img.shields.io/badge/xAI-TTS%20%7C%20Tools-111111?logo=x&logoColor=white)
![Streaming](https://img.shields.io/badge/Streaming-SSE-0F9D58)
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
</div>
> [!NOTE]
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
> 兼容改写和实测边界以本文为准。
| 文档属性 | 当前值 |
| --- | --- |
| 兼容快照 | **2026-07-16** |
| API 基址 | `/ai/v1` |
| 首选对话协议 | OpenAI Responses |
| 会话模式 | 无状态,客户端携带完整上下文 |
<a id="quick-start"></a>
## 快速接入
### 基础地址与鉴权
网关密钥在管理面板中创建。连接信息如下:
| 项目 | 配置 |
| --- | --- |
| Base URL | `https://<网关地址>/ai/v1` |
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
| API Key 鉴权 | `x-api-key: sk-...` |
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
列表与路由中移除。
可先用模型列表验证地址与密钥:
```bash
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>
## 端点一览
| 协议域 | 端点 | 角色 | 流式 |
| --- | --- | --- | :---: |
| 对话 | `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
| 对话 | `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
| 对话 | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
| 向量 | `POST /ai/v1/embeddings` | OpenAI Embeddings | — |
| 语音 | `POST /ai/v1/audio/speech` | OpenAI Audio Speech 外壳 | — |
| 语音 | `POST /ai/v1/tts` | xAI TTS 格式转换层 | — |
| 检索 | `POST /ai/v1/rerank` | Cohere / Jina 风格文档重排 | — |
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
### 如何选择协议
| 使用场景 | 推荐接口 |
| --- | --- |
| 新客户端、推理模型、服务端工具 | **Responses** |
| Anthropic SDK、Claude 生态客户端 | **Messages** |
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
| 向量、语音、重排与安全审核 | 对应专用端点 |
<a id="routing"></a>
## 路由与全局行为
### 请求链路
```mermaid
flowchart LR
A[客户端] --> B[网关密钥鉴权]
B --> C[分组、白名单与全局过滤]
C --> D[模型与能力匹配]
D --> E[选择最小 Priority]
E --> F[同优先级按 Weight 加权]
F --> G[OCI GenAI]
G -. 可重试且流未建立 .-> D
```
单次请求最多尝试三个渠道。模型不可用、限流、上游服务错误或网络错误可触发
换渠道;流式连接建立后不会切换渠道重试。
### 全局兼容边界
| 主题 | 当前行为 |
| --- | --- |
| 会话状态 | 网关不保存会话历史,客户端必须在每次请求中携带完整上下文 |
| 上游存储 | Responses 请求始终强制 `store:false` |
| 有状态字段 | Responses 拒绝非空 `previous_response_id`、非 `null``conversation``background:true` |
| 对话上游 | 对话请求统一进入 OCI OpenAI 兼容面,当前供给以 `xai.``meta.``openai.` 前缀模型为主 |
| 推理强度 | Responses `reasoning.effort`、Messages `output_config.effort`、Chat `reasoning_effort` 会传给上游;可用档位由模型决定 |
| 服务端工具 | Responses 支持 `web_search``x_search``code_interpreter` 和远程 `mcp`;命名容器管理与 File Search 不提供 |
| 文件输入 | `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 接入
### 主配置
自定义模型提供方必须写在用户级 `~/.codex/config.toml`。Codex 的项目级
`.codex/config.toml` 不允许改写 `model_provider``model_providers`
```toml
model = "xai.grok-4.3"
model_provider = "oci"
[model_providers.oci]
name = "oci-portal"
base_url = "https://<网关地址>/ai/v1"
env_key = "OCI_PORTAL_KEY"
wire_api = "responses"
```
### 自定义子代理
新版 Codex 可在 `~/.codex/agents/` 或项目级 `.codex/agents/` 放置独立 TOML
文件,并为子代理覆盖模型。每个文件都需要 `name``description`
`developer_instructions`
```toml
# .codex/agents/oci-worker.toml
name = "oci-worker"
description = "通过 OCI Portal 网关执行通用开发任务。"
developer_instructions = """
完成被分配的开发任务,保持改动聚焦,并返回验证结果。
"""
model = "xai.grok-4.3"
```
> [!IMPORTANT]
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
Codex 工具兼容现状:
| 工具形态 | 网关处理 | 验证状态 |
| --- | --- | --- |
| `function` | 原样透传 | 可用 |
| `namespace` | 子工具拍平为限定名 `function`,响应时还原 | multi-agent 已端到端实测;MCP 同路径有单测 |
| `custom` | 顶层普通工具转换为 `function`,调用与历史记录双向回转 | 可用,但存在降级边界 |
| `custom:apply_patch` | 直接剥离,让模型回落到其他编辑方式 | 有意限制 |
| `tool_search` | namespace 已完整展开,目录搜索语义冗余,直接剥离 | 有意降级 |
配置语法参考 [Codex Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
与 [Codex Advanced Configuration](https://learn.chatgpt.com/docs/config-file/config-advanced)。
<a id="limitations"></a>
## 已知限制
### `instructions` / `tools` 大体量流式断流
> [!WARNING]
> `instructions` 与 `tools` 两个字段的原始 JSON 值合计超过约 **64.5 KB** 时,
> 上游流式请求可能在推理阶段静默断连:连接直接 EOF,不发送 `error` 或终态事件。
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。2026-07-21
再次复测(Chicago 签名路径)仍未修复:`70,463 B` 两次均在数个推理 delta 后纯
EOF,同时段 `59,651 B` 对照正常 `completed`。本文及设置页中的
`KB` 均按 `1024 B` 计算。
| 协议 | 网关保护 | 客户端表现 |
| --- | --- | --- |
| 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>
### `multi-agent` 加密推理内容流式断流
> [!WARNING]
> `xai.grok-4.20-multi-agent` 请求同时启用 `stream: true` 与
> `include: ["reasoning.encrypted_content"]` 时,上游高概率在序列化大体量
> `encrypted_content` 事件期间静默断开连接,不返回 `error` 或终态事件。
> 断点常见于 `output_index: 8` 附近(第三次搜索结束后的大加密推理块),
> 也观测到更早(推理起始阶段)与更晚(15 块之后的文本输出阶段)断开。
2026-07-21 复测仍存在,并进一步定界:
- 网关(Phoenix 渠道 API Key)四次全断:两次断于 `output_index: 8` 搜索阶段
(此前 8 块加密内容累计约 `364 KB`),一次收满 15 块约 `762 KB` 后断于文本
输出;Chicago 签名直发同样可断(亦有一次仅 2 块 `35 KB` 的小规模会话正常
完成)——与区域、鉴权方式无关,与加密块规模相关。
- 该模型单块 `encrypted_content``47 KB`,比 `xai.grok-4.3`(约
`2-10 KB`)大一个量级;`grok-4.3` + `web_search` + 同 `include` 在累计
`148 KB` 加密内容、`output_index: 15` 下流式完整——断流特定于
`multi-agent` 模型的大加密块序列化。
- 对照:同请求仅改 `include: ["web_search_call.action.sources"]`(不含加密
推理)正常完成。规避方式:该模型流式时不请求
`reasoning.encrypted_content`
### ZDR 与文件输入
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
```text
File content is currently unsupported for ZDR customers
```
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
<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)
- [OpenAI Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)
- [OpenAI Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)
- [OpenAI Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create)
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
</details>
| 标记 | 含义 |
| :---: | --- |
| ✅ | 网关直接支持 |
| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
| 🔄 | 网关执行字段或协议转换后支持 |
| ◐ | 部分支持、存在前置条件或语义降级 |
<a id="compat-responses"></a>
### OpenAI Responses
**`POST /ai/v1/responses` · 无状态主接口**
网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
<details open>
<summary><strong>核心字段</strong></summary>
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填;通过密钥白名单并匹配可用渠道后送往上游 |
| `input` | ➡️ | 支持字符串或 item 数组;普通内容保留,Codex 工具历史项可能改写 |
| `instructions``max_output_tokens` | ➡️ | 类型可解析后保留,不做范围或模型能力校验 |
| `temperature``top_p` | ➡️ | 保留,不做取值范围校验 |
| `parallel_tool_calls` | ◐ | 通常保留;若全部工具被剥离,则与 `tool_choice` 一并删除 |
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个对象保留;是否生效由 OCI 模型决定 |
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
| `stream` | ◐ | 支持 SSE`instructions``tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations) |
| 其余标准与未知顶层字段 | ➡️ | `context_management``include``metadata``prompt``prompt_cache_key``service_tier``truncation``user` 等均保留,由 OCI 决定是否接受 |
</details>
<details>
<summary><strong>工具兼容</strong></summary>
| 工具或参数 | 状态 | 网关行为 |
| --- | :---: | --- |
| `tools[].type=function` | ➡️ | 非流式与流式均可,工具对象保留 |
| `web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具;参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools) |
| `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 补丁格式训练,模型应回落其他编辑方式 |
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
</details>
本地拒绝(返回 400):
- 非空 `previous_response_id`
-`null``conversation`
- `background:true`
- 未列入白名单的工具类型,例如 `web_search_preview``file_search`
`computer``image_generation``shell`
响应边界:
- 无工具还原且未触发流式升级时,普通响应保持直通语义
- namespace / custom 调用会在非流式响应与命中的 SSE `data` 事件中定向还原
- 普通 SSE 不补 `data: [DONE]`reasoning 增量不会被过滤
- 未知模型返回 404,无可用渠道返回 503
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
<a id="compat-chat"></a>
### OpenAI Chat Completions
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
请求先转换为 Responses,再将 OCI Responses 响应桥接回 Chat Completions。
只有下表字段会进入上游请求。
<details>
<summary><strong>展开字段矩阵</strong></summary>
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制 |
| `messages` | 🔄 | 必填,转换为 Responses `input` / `instructions` |
| `system` / `developer` 消息 | ◐ | 文本按顺序合并为 `instructions`;块数组中的非文本内容忽略 |
| `user` / `assistant` 文本 | 🔄 | 字符串和 `text` 块分别转为 `input_text` / `output_text` |
| `image_url` | ◐ | URL 或 data URI 转为 `input_image``detail` 忽略 |
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output` |
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
| `temperature``top_p``parallel_tool_calls` | ◐ | 写入 Responses,不校验范围或模型能力 |
| `stream` | 🔄 | Responses SSE 转为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
| `stream_options.include_usage` | 🔄 | 控制终块后的独立 usage 块 |
| `tools[].type=function` | ◐ | 支持 `name``description``parameters``strict` 忽略 |
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function |
| `response_format` | ◐ | `json_object``json_schema` 转为 Responses `text.format` |
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort` |
| `store` | 🔄 | 客户端取值忽略,上游始终使用 `store:false` |
</details>
不支持(返回 400):`input_audio``file``refusal` 等消息内容块,以及
`function` 之外的工具类型。
<details>
<summary><strong>静默忽略字段</strong></summary>
消息级 `name` / `refusal` / `audio` / 旧式 `function_call``stop``seed`
`n``frequency_penalty``presence_penalty``logprobs``top_logprobs`
`logit_bias``user``audio``modalities``prediction``metadata`
`moderation``prompt_cache_key``safety_identifier``service_tier`
`verbosity``web_search_options``stream_options.include_obfuscation`
及其他未知字段。
</details>
响应边界:
- 非流式固定生成一个 `choices[0]`;文本合并,函数调用转为 `tool_calls`
- `finish_reason` 只生成 `stop``tool_calls``length`
- reasoning、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
- usage 保留 `prompt_tokens``completion_tokens``total_tokens`
`prompt_tokens_details.cached_tokens`
- 已开始输出的流中断不会转换成标准 SSE 错误事件
<a id="compat-messages"></a>
### Anthropic Messages
**`POST /ai/v1/messages` · Anthropic 协议转换层**
支持 `Authorization: Bearer``x-api-key`,但不校验或使用
`anthropic-version``anthropic-beta` 请求头。
<details>
<summary><strong>展开顶层字段矩阵</strong></summary>
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
| `max_tokens` | 🔄 | 缺省或 ≤0 时使用 8192,再转为 `max_output_tokens` |
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
| `temperature``top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
| `tool_choice` | ◐ | 支持 `auto``any``none`、具名 `tool` |
| `output_config.effort` | 🔄 | 转小写后映射为 `reasoning.effort` |
</details>
内容块兼容:
| `messages[].content` | 状态 | 网关行为 |
| --- | :---: | --- |
| 字符串 / `text` | 🔄 | user 转 `input_text`assistant 历史转 `output_text` |
| `image` | ◐ | 仅支持 `base64``url` source |
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称与输入 |
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text`is_error` 与非文本结果丢失 |
| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
不支持(返回 400):`document`、服务端工具结果及未知内容块。历史
`thinking` / `redacted_thinking` 会删除。
<details>
<summary><strong>静默忽略字段</strong></summary>
`top_k``stop_sequences``metadata``thinking``output_config` 其余子字段、
`cache_control``container``inference_geo``service_tier` 及其他未知顶层字段。
</details>
响应边界:
- 仅将 Responses `output_text` 转为 `text``function_call` 转为 `tool_use`
- `stop_reason` 只生成 `end_turn``tool_use``max_tokens`
- 不生成 Anthropic thinking / signature 内容块
- usage 只保留 `input_tokens``output_tokens``cache_read_input_tokens`
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
<a id="compat-embeddings"></a>
### OpenAI Embeddings
**`POST /ai/v1/embeddings` · 向量化专用端点**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
| 字符串 `input` | ✅ | 包装为单个输入后调用 OCI |
| 字符串数组 `input` | ✅ | 按原顺序调用 OCI |
| `dimensions` | ◐ | 映射为 OCI 输出维度,不校验范围或模型能力 |
| `encoding_format=float` | ✅ | 返回 float 数组;省略时相同 |
不支持(返回 400):token ID 数组、空数组、`null`
`encoding_format=base64``user` 与其他未知字段静默忽略。
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
<a id="compat-audio"></a>
### 语音生成
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
#### OpenAI Audio Speech
**`POST /ai/v1/audio/speech`**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
| `input` | ◐ | 必填非空,随后保留 |
| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
| `speed``instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
#### xAI TTS
**`POST /ai/v1/tts`**
接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)
转换为 OpenAI 兼容形态后进入同一上游。
| 字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `text` | 🔄 | 必填非空,转换为 `input` |
| `language` | ◐ | 必填,接受 BCP-47 或 `auto` |
| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
| `speed` | ➡️ | 保留,实测可用 |
| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
共同响应边界:
- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
- 不提供 HTTP 流式音频或 WebSocket 代理
- 无 token 用量口径,调用日志只记时延与渠道
<a id="compat-rerank"></a>
### Rerank
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
上游走 OCI typed 面,模型为 `cohere.rerank-v4.0-pro``-fast`
| 字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受白名单限制,需要 `RERANK` 能力渠道 |
| `query` | ✅ | 必填非空 |
| `documents` | ◐ | 仅接受字符串数组;旧版 `{"text": ...}` 对象数组返回 400 |
| `top_n` | ✅ | 可选,限制返回条数 |
| `return_documents` | ✅ | 为 `true` 时回带原文 |
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
<a id="compat-moderations"></a>
### Moderations
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| 字符串 `input` | ✅ | 单条审核 |
| 字符串数组 `input` | ✅ | 逐条审核,单次 1~8 条 |
不支持(返回 400):多模态 input、空字符串条目、空数组或超过 8 条。
`model` 接受但忽略,不参与白名单判断。
响应边界:
- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
- 任一维度得分 ≥ 0.5 时 `flagged=true`
- PII 命中放在扩展字段 `results[].pii`,包含 `text``label``score`
`offset``length`,不参与 `flagged`
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
- 响应 `model` 恒为 `oci-guardrails`
<a id="compat-models"></a>
### Models
**`GET /ai/v1/models` · 当前密钥可见模型列表**
响应使用 OpenAI Models 列表外壳,只返回同时通过以下条件的模型:
1. 存在于当前渠道目录
2. 匹配密钥绑定的渠道分组
3. 不在全局模型黑名单
4. 命中密钥模型白名单(配置时)
5. 未被「过滤弃用模型」开关排除
网关不提供标准的单模型检索端点。
<a id="implementation"></a>
## 附录:实现索引
| 端点 / 能力 | 主要实现 |
| --- | --- |
| Responses 直通与 Codex 工具兼容 | [`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) |
| Chat Completions 转换 | [`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) |
| Anthropic Messages 转换 | [`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) |
| 渠道路由与 Embeddings | [`aigateway_chat.go`](../internal/service/aigateway_chat.go) |
| TTS 上游 | [`genai_speech.go`](../internal/oci/genai_speech.go) · [`aigateway_extras.go`](../internal/service/aigateway_extras.go) |
| Rerank / Moderations 上游 | [`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`moderations.go`](../internal/aiwire/moderations.go) |
| HTTP Handler 与流式桥接 | [`aigateway.go`](../internal/api/aigateway.go) · [`aigateway_extras.go`](../internal/api/aigateway_extras.go) |
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
[返回顶部](#top)
+5402 -466
View File
File diff suppressed because it is too large Load Diff
+5403 -467
View File
File diff suppressed because it is too large Load Diff
+3588 -408
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/oracle/oci-go-sdk/v65 v65.120.0
github.com/oracle/oci-go-sdk/v65 v65.121.0
github.com/pquerna/otp v1.5.0
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
+2 -2
View File
@@ -119,8 +119,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oracle/oci-go-sdk/v65 v65.120.0 h1:qpGdts2Yleg6TdmtxXkL8MAnsASO+SOG+iG/Nd8wUGk=
github.com/oracle/oci-go-sdk/v65 v65.120.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw=
github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+7
View File
@@ -20,6 +20,13 @@ type MessagesRequest struct {
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
// OutputConfig 是 Anthropic 官方输出控制(effort 等);网关映射 effort 到 OCI reasoningEffort。
OutputConfig *AnthOutputConfig `json:"output_config,omitempty"`
}
// AnthOutputConfig 是 Anthropic Messages 顶层 output_config 对象。
type AnthOutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// SystemText 把顶层 system(string 或块数组)拍平为文本。
+5
View File
@@ -0,0 +1,5 @@
package aiwire
// AnyJSON 仅供 swagger 文档渲染:经 .swaggo 全局替换,代表任意 JSON 值
// (json.RawMessage 与 string/数组联合类型),运行时代码不使用。
type AnyJSON struct{}
+63
View File
@@ -0,0 +1,63 @@
package aiwire
import "encoding/json"
// ModerationsRequest 是 /ai/v1/moderations 请求体;input 兼容 OpenAI 的
// string 与 []string 两种形态,model 字段接受但忽略(上游为服务级 API)。
type ModerationsRequest struct {
Model string `json:"model,omitempty"`
Input json.RawMessage `json:"input"`
}
// ModerationPii 是一处 PII 命中(OCI 扩展,OpenAI 协议无对应物)。
type ModerationPii struct {
Text string `json:"text"`
Label string `json:"label"`
Score float64 `json:"score"`
Offset int `json:"offset"`
Length int `json:"length"`
}
// ModerationResult 是一条输入的审核结果:categories / category_scores 用
// OCI 原生维度(overall / blocklist / prompt_injection),pii 为扩展字段。
type ModerationResult struct {
Flagged bool `json:"flagged"`
Categories map[string]bool `json:"categories"`
CategoryScores map[string]float64 `json:"category_scores"`
Pii []ModerationPii `json:"pii,omitempty"`
}
// ModerationsResponse 是 /ai/v1/moderations 响应体(OpenAI moderations 外壳)。
type ModerationsResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Results []ModerationResult `json:"results"`
}
// SpeechRequest 描述 /ai/v1/audio/speech 请求体的已知字段(文档用途)。
// 直通端点实际按原样透传,未列字段与 xAI 专属参数(如 output_format)同样保留。
type SpeechRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Language string `json:"language,omitempty"`
}
// TtsRequest 描述 /ai/v1/tts 请求体的已知字段(xAI 官方 TTS 格式,文档用途)。
// model 为网关扩展字段(缺省 xai.grok-tts);未列字段原样透传上游。
type TtsRequest struct {
Model string `json:"model,omitempty"`
Text string `json:"text"`
Language string `json:"language"`
VoiceID string `json:"voice_id,omitempty"`
OutputFormat *TtsOutputFormat `json:"output_format,omitempty"`
Speed float64 `json:"speed,omitempty"`
}
// TtsOutputFormat 是 xAI TTS 输出格式配置(缺省 MP3 24kHz/128kbps)。
type TtsOutputFormat struct {
Codec string `json:"codec,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
BitRate int `json:"bit_rate,omitempty"`
}
+94 -121
View File
@@ -1,6 +1,6 @@
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Chat Completions(兼作网关内部
// 中间表示 IR,因 OCI GENERIC 格式即其镜像)与 Anthropic Messages。零内部依赖,
// oci 层与 service 层共同引用
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Chat Completions /
// Anthropic Messages / Embeddings 与通用记账、错误、模型列表结构。上游统一为
// OCI OpenAI 兼容面
package aiwire
import (
@@ -8,28 +8,6 @@ import (
"strings"
)
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;TopK 为 OCI/Anthropic 扩展字段。
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
Stop StringList `json:"stop,omitempty"`
Seed *int `json:"seed,omitempty"`
N *int `json:"n,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
type StringList []string
@@ -50,7 +28,75 @@ func (s *StringList) UnmarshalJSON(b []byte) error {
return nil
}
// StreamOptions 控制流式末尾是否附带 usage 事件
// Usage 是 token 用量
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
}
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
type PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
}
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
func (u *Usage) CachedTokens() int {
if u == nil || u.PromptTokensDetails == nil {
return 0
}
return u.PromptTokensDetails.CachedTokens
}
// Model 是 /v1/models 列表项。
type Model struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
// ModelList 是 /v1/models 响应体。
type ModelList struct {
Object string `json:"object"`
Data []Model `json:"data"`
}
// ErrorBody 是 OpenAI 格式错误响应。
type ErrorBody struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail 是错误明细。
type ErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code,omitempty"`
}
// ---- Chat Completions 线格式(/ai/v1/chat/completions,Tier 2 兼容层)----
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;只声明网关读取的字段,
// 无对应物的字段(stop / seed / n / penalty 等)由 JSON 解码天然忽略。
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// StreamOptions 控制流式末尾是否附带 usage 块。
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
@@ -59,48 +105,31 @@ type StreamOptions struct {
type ChatMessage struct {
Role string `json:"role"`
Content Content `json:"content"`
Name string `json:"name,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// Content 兼容 string 与多模态块数组两种线格式;一期仅透传文本
// Content 兼容 string 与多模态块数组两种线格式,序列化时保形
type Content struct {
// Text 在原始值为字符串时填充
Text string
// Parts 在原始值为数组时填充
Parts []ContentPart
// isArray 记录原始形态,序列化时保形
isArray bool
}
// ContentPart 是块数组中的一段;text 与 image_url 被网关处理,其余类型拒绝。
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址,与 OCI ImageUrl 同构。
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
IsArray bool
}
func (c *Content) UnmarshalJSON(b []byte) error {
trimmed := strings.TrimSpace(string(b))
if trimmed == "null" {
t := strings.TrimSpace(string(b))
if t == "null" {
return nil
}
if len(trimmed) > 0 && trimmed[0] == '[' {
c.isArray = true
if strings.HasPrefix(t, "[") {
c.IsArray = true
return json.Unmarshal(b, &c.Parts)
}
return json.Unmarshal(b, &c.Text)
}
func (c Content) MarshalJSON() ([]byte, error) {
if c.isArray {
if c.IsArray {
return json.Marshal(c.Parts)
}
return json.Marshal(c.Text)
@@ -111,14 +140,9 @@ func NewTextContent(text string) Content {
return Content{Text: text}
}
// NewPartsContent 构造块数组形态的内容(多模态消息用)。
func NewPartsContent(parts []ContentPart) Content {
return Content{Parts: parts, isArray: true}
}
// JoinText 返回全部文本内容(数组形态拼接 text 块)。
// JoinText 拍平内容为纯文本(数组形态拼接 text 块)。
func (c Content) JoinText() string {
if !c.isArray {
if !c.IsArray {
return c.Text
}
var sb strings.Builder
@@ -130,23 +154,20 @@ func (c Content) JoinText() string {
return sb.String()
}
// HasUnsupported 报告是否含网关无法承接的内容块(text / image_url 之外的类型)
func (c Content) HasUnsupported() bool {
for _, p := range c.Parts {
switch p.Type {
case "", "text":
case "image_url":
if p.ImageURL == nil || p.ImageURL.URL == "" {
return true
}
default:
return true
}
}
return false
// ContentPart 是块数组中的一段;text image_url 被网关承接,其余类型拒绝
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// Tool 是 function 工具定义
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
// Tool 是嵌套形态的 function 工具定义。
type Tool struct {
Type string `json:"type"`
Function FunctionDef `json:"function"`
@@ -178,7 +199,7 @@ type ResponseFormat struct {
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
}
// ChatResponse 是非流式响应体。
// ChatResponse 是非流式响应体(chat.completion)
type ChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
@@ -195,28 +216,6 @@ type Choice struct {
FinishReason string `json:"finish_reason"`
}
// Usage 是 token 用量。
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
}
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
type PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
}
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
func (u *Usage) CachedTokens() int {
if u == nil || u.PromptTokensDetails == nil {
return 0
}
return u.PromptTokensDetails.CachedTokens
}
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
type ChatChunk struct {
ID string `json:"id"`
@@ -254,29 +253,3 @@ type FunctionCallDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
// Model 是 /v1/models 列表项。
type Model struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
// ModelList 是 /v1/models 响应体。
type ModelList struct {
Object string `json:"object"`
Data []Model `json:"data"`
}
// ErrorBody 是 OpenAI 格式错误响应。
type ErrorBody struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail 是错误明细。
type ErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code,omitempty"`
}
+28
View File
@@ -0,0 +1,28 @@
package aiwire
// RerankRequest 是 /ai/v1/rerank 请求体(Jina / Cohere 通行风格)。
type RerankRequest struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
}
// RerankDocument 包装原文,仅在 return_documents 时返回。
type RerankDocument struct {
Text string `json:"text"`
}
// RerankResult 是一条重排结果:index 指向请求 documents 下标。
type RerankResult struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
Document *RerankDocument `json:"document,omitempty"`
}
// RerankResponse 是 /ai/v1/rerank 响应体。
type RerankResponse struct {
Model string `json:"model"`
Results []RerankResult `json:"results"`
}
+13 -33
View File
@@ -156,39 +156,6 @@ type RespReasoning struct {
Effort string `json:"effort,omitempty"`
}
// Response 是 /responses 响应对象。
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Model string `json:"model"`
Output []RespOutItem `json:"output"`
Usage *RespUsage `json:"usage,omitempty"`
Error *RespError `json:"error"`
IncompleteDetails *RespIncomplete `json:"incomplete_details"`
Store bool `json:"store"`
}
// RespOutItem 是 output 数组项:message 或 function_call。
type RespOutItem struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status,omitempty"`
Role string `json:"role,omitempty"`
Content []RespOutPart `json:"content,omitempty"`
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
// RespOutPart 是 message item 的内容部件(output_text)。
type RespOutPart struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []any `json:"annotations"`
}
// RespUsage 是 Responses 口径的用量(input/output 命名)。
type RespUsage struct {
InputTokens int `json:"input_tokens"`
@@ -217,3 +184,16 @@ type RespError struct {
type RespIncomplete struct {
Reason string `json:"reason"`
}
// RespResponse 描述 /ai/v1/responses 非流式响应的常用顶层字段(文档用途)。
// 直通端点原样返回上游 JSON,未列字段(reasoning、incomplete_details 等)同样保留。
type RespResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Model string `json:"model"`
Output json.RawMessage `json:"output"`
Usage *RespUsage `json:"usage,omitempty"`
Error *RespError `json:"error,omitempty"`
}
+52 -2
View File
@@ -1,8 +1,11 @@
package api
import (
"math"
"net/http"
"os"
"runtime"
"time"
"github.com/gin-gonic/gin"
)
@@ -12,13 +15,22 @@ import (
var (
buildVersion = "dev"
buildTime = ""
// startedAt 为进程启动时刻,运行时长与 CPU 均值的计算基准
startedAt = time.Now()
// aboutDB 由 main 启动时注入,存储指标据此定位数据库文件
aboutDB struct{ driver, path string }
)
// SetAboutRuntime 注入数据库形态,「关于」页存储指标展示用;启动时调用一次。
func SetAboutRuntime(dbDriver, dbPath string) {
aboutDB.driver, aboutDB.path = dbDriver, dbPath
}
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
//
// @Summary 返回构建运行环境信息,「设置 · 关于」页展示
// @Summary 返回构建运行时长与资源占用信息,「设置 · 关于」页展示
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} aboutResponse
// @Security BearerAuth
// @Router /api/v1/about [get]
func about(c *gin.Context) {
@@ -27,5 +39,43 @@ func about(c *gin.Context) {
"buildTime": buildTime,
"goVersion": runtime.Version(),
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"startedAt": startedAt.UTC().Format(time.RFC3339),
"uptimeSeconds": int64(time.Since(startedAt).Seconds()),
"resources": resourcesSnapshot(),
})
}
// resourcesSnapshot 采集进程资源占用:CPU 自启动均值、内存、goroutine 与存储。
func resourcesSnapshot() gin.H {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
pct := 0.0
if up := time.Since(startedAt).Seconds(); up > 0 {
// 多核并行时均值可超 100%,口径为「占单核百分比」
pct = processCPUTime().Seconds() / up * 100
}
return gin.H{
"cpuAvgPercent": math.Round(pct*100) / 100,
"numCpu": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(),
"memHeapBytes": ms.HeapAlloc,
"memSysBytes": ms.Sys,
"dbEngine": aboutDB.driver,
"dbBytes": dbFileBytes(aboutDB.driver, aboutDB.path),
}
}
// dbFileBytes 统计数据库磁盘占用:sqlite 累加主文件与 -wal / -shm,
// 外部数据库(mysql / postgres)不可从本机度量,返回 -1 表示不可用。
func dbFileBytes(driver, path string) int64 {
if driver != "sqlite" || path == "" {
return -1
}
var total int64
for _, p := range []string{path, path + "-wal", path + "-shm"} {
if fi, err := os.Stat(p); err == nil {
total += fi.Size()
}
}
return total
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package api
import "time"
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
func processCPUTime() time.Duration { return 0 }
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"os"
"path/filepath"
"testing"
)
func TestDBFileBytes(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "app.db")
mustWrite := func(p string, n int) {
t.Helper()
if err := os.WriteFile(p, make([]byte, n), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
mustWrite(dbPath, 100)
mustWrite(dbPath+"-wal", 30)
cases := []struct {
name string
driver string
path string
want int64
}{
{name: "sqlite 主文件加 wal", driver: "sqlite", path: dbPath, want: 130},
{name: "sqlite 文件不存在计 0", driver: "sqlite", path: filepath.Join(dir, "none.db"), want: 0},
{name: "外部数据库不可度量", driver: "postgres", path: "", want: -1},
{name: "路径为空不可度量", driver: "sqlite", path: "", want: -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := dbFileBytes(tc.driver, tc.path); got != tc.want {
t.Fatalf("dbFileBytes(%q, %q) = %d, want %d", tc.driver, tc.path, got, tc.want)
}
})
}
}
func TestResourcesSnapshot(t *testing.T) {
SetAboutRuntime("sqlite", filepath.Join(t.TempDir(), "absent.db"))
got := resourcesSnapshot()
if got["numCpu"].(int) < 1 || got["goroutines"].(int) < 1 {
t.Fatalf("numCpu / goroutines 应为正数: %+v", got)
}
if got["memHeapBytes"].(uint64) == 0 || got["memSysBytes"].(uint64) == 0 {
t.Fatalf("内存指标应为正数: %+v", got)
}
if got["cpuAvgPercent"].(float64) < 0 {
t.Fatalf("cpuAvgPercent 不应为负: %+v", got)
}
if got["dbEngine"].(string) != "sqlite" || got["dbBytes"].(int64) != 0 {
t.Fatalf("存储指标不符: %+v", got)
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build unix
package api
import (
"syscall"
"time"
)
// processCPUTime 返回进程自启动累计的 CPU 时间(用户态 + 内核态);
// 取不到时返回 0,「关于」页 CPU 均值显示为 0 而非报错。
func processCPUTime() time.Duration {
var ru syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
return 0
}
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
}
+226 -12
View File
@@ -3,9 +3,13 @@ package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/aiwire" // swagger 注解引用
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service"
)
@@ -18,7 +22,7 @@ type aiAdminHandler struct {
// @Summary ---- 密钥 ----
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[model.AiKey]
// @Security BearerAuth
// @Router /api/v1/ai-keys [get]
func (h *aiAdminHandler) listKeys(c *gin.Context) {
@@ -35,7 +39,7 @@ func (h *aiAdminHandler) listKeys(c *gin.Context) {
// @Summary 生成密钥
// @Tags AI 管理
// @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any
// @Success 201 {object} aiKeyCreateResponse "key 为明文密钥,仅本次返回"
// @Security BearerAuth
// @Router /api/v1/ai-keys [post]
func (h *aiAdminHandler) createKey(c *gin.Context) {
@@ -43,12 +47,13 @@ func (h *aiAdminHandler) createKey(c *gin.Context) {
Name string `json:"name" binding:"required"`
Value string `json:"value"`
Group string `json:"group"`
Models []string `json:"models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group)
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group, req.Models)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -72,12 +77,13 @@ func (h *aiAdminHandler) updateKey(c *gin.Context) {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Group *string `json:"group"`
Models *[]string `json:"models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group); err != nil {
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group, req.Models); err != nil {
respondError(c, err)
return
}
@@ -108,7 +114,7 @@ func (h *aiAdminHandler) deleteKey(c *gin.Context) {
// @Tags AI 管理
// @Param id path int true "配置 ID"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} model.AiKey
// @Security BearerAuth
// @Router /api/v1/ai-keys/{id}/content-log [put]
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
@@ -135,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
//
// @Summary 分页查询内容日志
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Success 200 {object} pagedResponse[model.AiCallLog]
// @Security BearerAuth
// @Router /api/v1/ai-content-logs [get]
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
@@ -155,7 +161,7 @@ func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
// @Summary ---- 渠道 ----
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[model.AiChannel]
// @Security BearerAuth
// @Router /api/v1/ai-channels [get]
func (h *aiAdminHandler) listChannels(c *gin.Context) {
@@ -170,7 +176,7 @@ func (h *aiAdminHandler) listChannels(c *gin.Context) {
// @Summary 创建 AI 渠道
// @Tags AI 管理
// @Param body body service.ChannelInput true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} model.AiChannel
// @Security BearerAuth
// @Router /api/v1/ai-channels [post]
func (h *aiAdminHandler) createChannel(c *gin.Context) {
@@ -234,7 +240,7 @@ func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
// @Tags AI 管理
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} model.AiChannel
// @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/probe [post]
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
@@ -253,7 +259,7 @@ func (h *aiAdminHandler) probeChannel(c *gin.Context) {
// @Summary 同步渠道模型缓存
// @Tags AI 管理
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[model.AiModelCache]
// @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/sync-models [post]
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
@@ -269,11 +275,60 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": models})
}
// @Summary 渠道模型缓存列表
// @Tags AI 管理
// @Param id path int true "渠道 ID"
// @Success 200 {object} itemsResponse[model.AiModelCache]
// @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/models [get]
func (h *aiAdminHandler) listChannelModels(c *gin.Context) {
id, ok := aiPathID(c)
if !ok {
return
}
models, err := h.gw.ChannelModels(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": models})
}
type testChannelModelRequest struct {
Model string `json:"model" binding:"required"`
}
// @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)
// @Tags AI 管理
// @Param id path int true "渠道 ID"
// @Param body body testChannelModelRequest true "模型名"
// @Success 200 {object} model.AiChannel
// @Failure 502 {object} errorResponse "试调未通过"
// @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/test-model [post]
func (h *aiAdminHandler) testChannelModel(c *gin.Context) {
id, ok := aiPathID(c)
if !ok {
return
}
var req testChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ch)
}
// ---- 聚合模型与调用日志 ----
// @Summary ---- 聚合模型与调用日志 ----
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[aiwire.Model]
// @Security BearerAuth
// @Router /api/v1/ai-models [get]
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
@@ -285,9 +340,67 @@ func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": list.Data})
}
// ---- 模型黑名单 ----
// @Summary 模型黑名单列表
// @Tags AI 管理
// @Success 200 {object} itemsResponse[model.AiModelBlacklist]
// @Security BearerAuth
// @Router /api/v1/ai-blacklist [get]
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
items, err := h.gw.Blacklist(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// addBlacklist 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤。
//
// @Summary 添加模型黑名单
// @Tags AI 管理
// @Param body body object true "请求体:{name: 模型名}"
// @Success 201 {object} itemResponse[model.AiModelBlacklist]
// @Security BearerAuth
// @Router /api/v1/ai-blacklist [post]
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item, err := h.gw.AddBlacklist(c.Request.Context(), req.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"item": item})
}
// @Summary 移除模型黑名单(重新同步后模型恢复入池)
// @Tags AI 管理
// @Param id path int true "黑名单条目 ID"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/ai-blacklist/{id} [delete]
func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
id, ok := aiPathID(c)
if !ok {
return
}
if err := h.gw.RemoveBlacklist(c.Request.Context(), id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary AI 调用日志列表
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Success 200 {object} pagedResponse[model.AiContentLog]
// @Security BearerAuth
// @Router /api/v1/ai-logs [get]
func (h *aiAdminHandler) listLogs(c *gin.Context) {
@@ -309,3 +422,104 @@ 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"`
// UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次
// 尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s
UpstreamWaitSeconds int `json:"upstreamWaitSeconds"`
}
// 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,
UpstreamWaitSeconds: int(h.gw.UpstreamWait() / time.Second),
}
}
// aiSettings 返回 AI 网关全局设置。
//
// @Summary AI 网关全局设置
// @Tags AI 管理
// @Success 200 {object} aiSettingsResponse
// @Security BearerAuth
// @Router /api/v1/ai-settings [get]
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
c.JSON(http.StatusOK, h.currentAiSettings())
}
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
//
// @Summary 更新 AI 网关全局设置
// @Tags AI 管理
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒"
// @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) {
var req aiSettingsResponse
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
return
}
if req.UpstreamWaitSeconds < 30 || req.UpstreamWaitSeconds > 900 {
c.JSON(http.StatusBadRequest, gin.H{"error": "upstreamWaitSeconds 须在 30..900"})
return
}
ctx := c.Request.Context()
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
respondError(c, err)
return
}
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
}
if err := h.gw.SetUpstreamWait(ctx, req.UpstreamWaitSeconds); err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, h.currentAiSettings())
}
+461 -184
View File
@@ -1,13 +1,16 @@
package api
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"slices"
"strings"
"time"
@@ -86,6 +89,25 @@ func keyGroup(c *gin.Context) string {
return ""
}
// keyModels 取当前请求密钥的模型白名单(空 = 不限模型)。
func keyModels(c *gin.Context) []string {
if key, ok := c.Get(aiKeyCtx); ok {
return key.(*model.AiKey).Models
}
return nil
}
// checkKeyModel 校验请求模型是否在密钥白名单内;拒绝时按端点协议返回
// 404 model_not_found(与未知模型同口径,不泄露密钥配置细节)并返回 false。
func checkKeyModel(c *gin.Context, modelName string) bool {
allowed := keyModels(c)
if len(allowed) == 0 || slices.Contains(allowed, modelName) {
return true
}
aiError(c, http.StatusNotFound, "model_not_found", "模型不存在或无权访问: "+modelName)
return false
}
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
entry := model.AiCallLog{
@@ -102,21 +124,6 @@ func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string,
}
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
func validateIR(ir aiwire.ChatRequest) error {
if strings.TrimSpace(ir.Model) == "" {
return fmt.Errorf("model 不能为空")
}
if len(ir.Messages) == 0 {
return fmt.Errorf("messages 不能为空")
}
for _, m := range ir.Messages {
if m.Content.HasUnsupported() {
return service.ErrAiUnsupportedBlock
}
}
return nil
}
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
@@ -147,47 +154,6 @@ func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, req
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
}
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点。
//
// @Summary OpenAI 兼容对话补全
// @Tags AI 网关
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
// @Router /ai/v1/chat/completions [post]
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
var ir aiwire.ChatRequest
if err := c.ShouldBindJSON(&ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if ir.Stream {
h.streamOpenAI(c, ir)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "openai", ir.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
return
}
resp.ID = aiRandID("chatcmpl-")
if resp.Created == 0 {
resp.Created = time.Now().Unix()
}
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", ir.Model, false, ir, resp)
c.JSON(http.StatusOK, resp)
}
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
if u == nil {
return
@@ -200,8 +166,8 @@ func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
//
// @Summary OpenAI 兼容向量嵌入
// @Tags AI 网关
// @Param body body object true "OpenAI embeddings 请求体"
// @Success 200 {object} map[string]any "OpenAI 兼容响应"
// @Param body body aiwire.EmbeddingsRequest true "OpenAI embeddings 请求体"
// @Success 200 {object} aiwire.EmbeddingsResponse "OpenAI 兼容响应"
// @Router /ai/v1/embeddings [post]
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
var req aiwire.EmbeddingsRequest
@@ -217,6 +183,9 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
return
}
if !checkKeyModel(c, req.Model) {
return
}
start := time.Now()
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
@@ -235,44 +204,6 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// streamOpenAI 以 OpenAI SSE 直通流式响应,末尾发 [DONE]。
func (h *aiGatewayHandler) streamOpenAI(c *gin.Context, ir aiwire.ChatRequest) {
start := time.Now()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "openai", ir.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
return
}
defer stream.Close()
sseHeaders(c)
id, created := aiRandID("chatcmpl-"), time.Now().Unix()
var usage *aiwire.Usage
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
entry.ErrMsg = err.Error()
}
break
}
chunk.ID, chunk.Created = id, created
if chunk.Usage != nil {
usage = chunk.Usage
}
writeSSEData(c, chunk)
}
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", ir.Model, true, ir, nil)
}
func sseHeaders(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
@@ -280,23 +211,16 @@ func sseHeaders(c *gin.Context) {
c.Writer.Flush()
}
func writeSSEData(c *gin.Context, v any) {
b, err := json.Marshal(v)
if err != nil {
return
}
c.Writer.WriteString("data: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
c.Writer.Flush()
}
// anthDefaultMaxTokens 是 max_tokens 缺省时的默认输出上限:Anthropic 协议
// 该字段必填,但部分客户端当选填不传,按默认值放行而非 400 拒绝。
const anthDefaultMaxTokens = 8192
// messages 是 Anthropic /ai/v1/messages 端点。
//
// @Summary Anthropic Messages 兼容端点
// @Tags AI 网关
// @Param body body object true "Anthropic messages 请求体(支持 stream)"
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
// @Param body body aiwire.MessagesRequest true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通;max_tokens 可缺省,默认 8192)"
// @Success 200 {object} aiwire.MessagesResponse "Anthropic 兼容响应(非流式;流式为 SSE 事件序列)"
// @Router /ai/v1/messages [post]
func (h *aiGatewayHandler) messages(c *gin.Context) {
var req aiwire.MessagesRequest
@@ -305,72 +229,139 @@ func (h *aiGatewayHandler) messages(c *gin.Context) {
return
}
if req.MaxTokens <= 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
req.MaxTokens = anthDefaultMaxTokens
}
if len(req.Messages) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
return
}
ir, err := service.AnthropicToIR(req)
if !checkKeyModel(c, req.Model) {
return
}
body, err := service.AnthropicToResponsesBody(req)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamAnthropic(c, ir)
h.streamAnthropic(c, body, req)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
if err != nil {
aiError(c, http.StatusBadGateway, "api_error", err.Error())
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
h.maybeLogContent(c, callID, "anthropic", req.Model, false, req, out)
c.JSON(http.StatusOK, out)
}
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
// streamAnthropic 流式直通并桥接:上游 Responses SSE 事件经 AnthRespBridge
// 转为 Anthropic 事件序列逐块写出;usage 从 completed 事件记账。
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwire.MessagesRequest) {
start := time.Now()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
h.logFailure(c, entry, req)
return
}
defer stream.Close()
defer upstream.Close()
sseHeaders(c)
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
emitted := 0
if err := forwardSSEData(upstream, func(data []byte) {
evs := bridge.Feed(data)
emitted += len(evs)
writeAnthEvents(c, evs)
}); err != nil {
entry.ErrMsg = err.Error()
}
break
}
writeAnthEvents(c, st.Feed(chunk))
}
writeAnthEvents(c, st.Finish())
// 上游断流且客户端尚未收到任何事件(OCI 兼容面对大请求 + 推理模型的
// 流式通道会在 reasoning 阶段掐断):降级非流式重做,结果按事件序列推送
if emitted == 0 && !bridge.SawTerminal() && c.Request.Context().Err() == nil {
if h.anthFallback(c, &entry, req) {
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
usage := st.Usage()
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
return
}
}
writeAnthEvents(c, bridge.Finish())
// 上游错误事件 / 提前终止:SSE 头已发出维持 200,错误落日志可查
if msg := bridge.Err(); msg != "" && entry.ErrMsg == "" {
entry.ErrMsg = msg
}
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
usage := bridge.Usage()
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
entry.CachedTokens = usage.CacheReadInputTokens
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "anthropic", ir.Model, true, ir, nil)
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
}
// anthFallback 用非流式重做同一请求并把完整结果按事件序列推送;
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
func (h *aiGatewayHandler) anthFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.MessagesRequest) bool {
req.Stream = false
body, err := service.AnthropicToResponsesBody(req)
if err != nil {
return false
}
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
if err != nil {
return false
}
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
if err != nil {
return false
}
writeAnthEvents(c, service.AnthMessageEvents(out))
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
entry.Retries++
entry.ErrMsg = "流式上游断流,已降级非流式完成"
fillUsage(entry, service.RespPassthroughUsage(payload))
return true
}
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
// Anthropic 与 Chat Completions 两条流式桥共用。
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
reader := bufio.NewReader(upstream)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
trimmed := bytes.TrimSpace(line)
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
emit(data)
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
@@ -386,11 +377,159 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
c.Writer.Flush()
}
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层,
// 承接只会说 Chat Completions 的存量客户端)。
//
// @Summary OpenAI Chat Completions 兼容端点
// @Tags AI 网关
// @Param body body aiwire.ChatRequest true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
// @Success 200 {object} aiwire.ChatResponse "OpenAI 兼容响应(非流式;流式为 SSE,末尾 data: [DONE])"
// @Router /ai/v1/chat/completions [post]
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
var req aiwire.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空")
return
}
if !checkKeyModel(c, req.Model) {
return
}
body, err := service.ChatToResponsesBody(req)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamChat(c, body, req)
return
}
h.chatOnce(c, body, req)
}
// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。
func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) {
start := time.Now()
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "openai", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
if err != nil {
aiError(c, http.StatusBadGateway, "api_error", err.Error())
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
fillUsage(&entry, out.Usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", req.Model, false, req, out)
c.JSON(http.StatusOK, out)
}
// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为
// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。
func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) {
start := time.Now()
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "openai", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
defer upstream.Close()
sseHeaders(c)
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
emitted := 0
if err := forwardSSEData(upstream, func(data []byte) {
chunks := bridge.Feed(data)
emitted += len(chunks)
writeChatChunks(c, chunks)
}); err != nil {
entry.ErrMsg = err.Error()
}
// 上游断流且客户端尚未收到任何块:降级非流式重做(成因同 messages 端点)
if emitted == 0 && bridge.Usage() == nil && c.Request.Context().Err() == nil {
if h.chatFallback(c, &entry, req, includeUsage) {
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
return
}
}
writeChatChunks(c, bridge.Finish())
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
// 未见 completed 即结束:usage 缺失说明上游流异常提前终止,落日志可查
if bridge.Usage() == nil && entry.ErrMsg == "" {
entry.ErrMsg = "上游流提前终止,未返回终态事件"
}
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, bridge.Usage())
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
}
// chatFallback 用非流式重做同一请求并把完整结果按 chunk 序列推送;
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
func (h *aiGatewayHandler) chatFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.ChatRequest, includeUsage bool) bool {
req.Stream = false
body, err := service.ChatToResponsesBody(req)
if err != nil {
return false
}
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
if err != nil {
return false
}
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
if err != nil {
return false
}
writeChatChunks(c, service.ChatResponseChunks(out, includeUsage))
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
entry.Retries++
entry.ErrMsg = "流式上游断流,已降级非流式完成"
fillUsage(entry, service.RespPassthroughUsage(payload))
return true
}
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
for _, ch := range chunks {
b, err := json.Marshal(ch)
if err != nil {
continue
}
c.Writer.WriteString("data: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
}
if len(chunks) > 0 {
c.Writer.Flush()
}
}
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
//
// @Summary 可用模型列表
// @Tags AI 网关
// @Success 200 {object} map[string]any "OpenAI 兼容 models 列表"
// @Success 200 {object} aiwire.ModelList "OpenAI 兼容 models 列表"
// @Router /ai/v1/models [get]
func (h *aiGatewayHandler) listModels(c *gin.Context) {
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
@@ -398,6 +537,15 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
return
}
if allowed := keyModels(c); len(allowed) > 0 {
kept := make([]aiwire.Model, 0, len(list.Data))
for _, m := range list.Data {
if slices.Contains(allowed, m.ID) {
kept = append(kept, m)
}
}
list.Data = kept
}
c.JSON(http.StatusOK, list)
}
@@ -405,86 +553,215 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
//
// @Summary OpenAI Responses 兼容端点
// @Tags AI 网关
// @Param body body object true "OpenAI responses 请求体(支持 stream)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转 custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access 上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)"
// @Success 200 {object} aiwire.RespResponse "OpenAI 兼容响应(非流式;流式为 SSE);直通仅建模常用字段,未列字段原样返回"
// @Router /ai/v1/responses [post]
func (h *aiGatewayHandler) responses(c *gin.Context) {
var req aiwire.RespRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
ir, err := service.ResponsesToIR(req)
raw, err := c.GetRawData()
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
var req aiwire.RespRequest
if err := json.Unmarshal(raw, &req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if !checkKeyModel(c, req.Model) {
return
}
h.responsesPassthrough(c, raw, req)
}
// responsesPassthrough 直通链路(唯一上游):原始 body 改写后直达
// OCI /actions/v1/responses,响应原样透传。
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
if err := service.RespPassthroughValidate(req); err != nil {
log.Printf("responses 直通(model=%s): 校验拒绝: %v", req.Model, err)
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
body, compat, err := service.RespPassthroughBody(raw)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
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 {
h.streamResponses(c, ir)
if on, kb := h.gw.StreamGuard(); on && service.RespGuardBytes(body) > kb*1024 {
h.responsesStreamUpgrade(c, body, req, compat)
return
}
h.responsesPassthroughStream(c, body, req, compat)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
payload = service.RespRestoreToolCalls(payload, compat)
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
c.JSON(http.StatusOK, out)
h.maybeLogContent(c, callID, "responses", req.Model, false, req, json.RawMessage(payload))
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
}
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
// logRespCompat 记录直通请求的 codex 兼容改写动作(观测)
func logRespCompat(model string, compat service.RespCompat) {
if len(compat.Flattened) == 0 && len(compat.Dropped) == 0 && len(compat.Converted) == 0 {
return
}
var parts []string
if len(compat.Flattened) > 0 {
parts = append(parts, "拍平: "+strings.Join(compat.Flattened, ", "))
}
if len(compat.Converted) > 0 {
parts = append(parts, "转换: "+strings.Join(compat.Converted, ", "))
}
if len(compat.Dropped) > 0 {
parts = append(parts, "剥离: "+strings.Join(compat.Dropped, ", "))
}
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
}
// 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()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
nsBody, err := service.RespDisableStream(body)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
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 {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
h.logFailure(c, entry, req)
return
}
defer stream.Close()
sseHeaders(c)
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
entry.ErrMsg = err.Error()
}
break
}
writeRespEvents(c, st.Feed(chunk))
}
writeRespEvents(c, st.Finish())
payload = service.RespRestoreToolCalls(payload, compat)
writeSynthSSE(c, payload)
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, st.Usage())
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", ir.Model, true, ir, nil)
h.maybeLogContent(c, callID, "responses", req.Model, true, req, json.RawMessage(payload))
}
func writeRespEvents(c *gin.Context, events []service.RespEvent) {
for _, ev := range events {
b, err := json.Marshal(ev.Data)
// writeSynthSSE 把完整响应按合成事件序列写出;合成失败时降级为一次性 JSON,
// 客户端至少拿到完整结果。
func writeSynthSSE(c *gin.Context, payload []byte) {
events, err := service.RespSynthSSEEvents(payload)
if err != nil {
continue
log.Printf("responses 直通: 合成 SSE 失败,降级 JSON 返回: %v", err)
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
return
}
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
sseHeaders(c)
for _, ev := range events {
c.Writer.Write([]byte("data: "))
c.Writer.Write(ev)
c.Writer.Write([]byte("\n\n"))
}
c.Writer.Flush()
}
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
// 逐行扫描 completed 事件提取 usage 记账;refs 非空时对 function_call 事件做
// namespace 还原后再转发。
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
start := time.Now()
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
defer upstream.Close()
sseHeaders(c)
usage, upErr, err := forwardSSE(c, upstream, compat)
if err != nil {
entry.ErrMsg = err.Error()
} else if upErr != "" {
// 上游错误事件已原样转发给客户端,这里落日志可查
entry.ErrMsg = upErr
} else if usage == nil {
entry.ErrMsg = "上游流提前终止,未返回终态事件"
}
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", req.Model, true, req, nil)
}
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息;
// refs 非空时 data 行先做 namespace 还原(未改动的行原样转发)。
func forwardSSE(c *gin.Context, upstream io.Reader, compat service.RespCompat) (*aiwire.Usage, string, error) {
reader := bufio.NewReader(upstream)
var usage *aiwire.Usage
var upErr string
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
trimmed := bytes.TrimSpace(line)
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
c.Writer.Write(restoreSSELine(line, data, compat))
if u := service.RespStreamCompletedUsage(data); u != nil {
usage = u
}
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
upErr = m
}
} else {
c.Writer.Write(line)
if len(trimmed) == 0 {
c.Writer.Flush()
}
}
}
if err != nil {
c.Writer.Flush()
if errors.Is(err, io.EOF) {
return usage, upErr, nil
}
return usage, upErr, err
}
}
}
// restoreSSELine 对一行 data 事件做工具调用项还原(namespace/custom),
// 未改动时原行透传(字节级直通)。
func restoreSSELine(line, data []byte, compat service.RespCompat) []byte {
if !compat.NeedRestore() {
return line
}
restored, changed := service.RespRestoreToolCallsEvent(data, compat)
if !changed {
return line
}
out := make([]byte, 0, len(restored)+8)
out = append(out, "data: "...)
out = append(out, restored...)
out = append(out, '\n')
return out
}
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/aiwire"
"oci-portal/internal/service"
)
// audioSpeech 是 OpenAI /ai/v1/audio/speech 端点(TTS,直通兼容面)。
//
// @Summary OpenAI Audio Speech 兼容端点(文本转语音)
// @Tags AI 网关
// @Param body body aiwire.SpeechRequest true "OpenAI audio speech 请求体(model/input 必填,voice 见 xAI Grok Voice 列表,language 缺省 auto;未列字段原样透传)"
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
// @Router /ai/v1/audio/speech [post]
func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
raw, err := c.GetRawData()
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
modelName, body, err := service.SpeechBodyNormalize(raw)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
h.speechRespond(c, "speech", modelName, raw, body)
}
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
//
// @Summary xAI 官方格式文本转语音端点
// @Tags AI 网关
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
// @Router /ai/v1/tts [post]
func (h *aiGatewayHandler) tts(c *gin.Context) {
raw, err := c.GetRawData()
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
modelName, body, err := service.TtsBodyConvert(raw)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
h.speechRespond(c, "tts", modelName, raw, body)
}
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
if !checkKeyModel(c, modelName) {
return
}
start := time.Now()
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, string(raw))
return
}
entry.Status = http.StatusOK
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
if contentType == "" {
contentType = "audio/mpeg"
}
c.Data(http.StatusOK, contentType, audio)
}
// rerank 是 /ai/v1/rerank 端点(Jina / Cohere 风格文档重排)。
//
// @Summary 文档重排端点(Cohere Rerank)
// @Tags AI 网关
// @Param body body aiwire.RerankRequest true "重排请求体(model/query/documents 必填,可选 top_n/return_documents)"
// @Success 200 {object} aiwire.RerankResponse "重排结果(results[].index 指向入参下标)"
// @Router /ai/v1/rerank [post]
func (h *aiGatewayHandler) rerank(c *gin.Context) {
var req aiwire.RerankRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.Query) == "" || len(req.Documents) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "model、query 与 documents 不能为空")
return
}
if !checkKeyModel(c, req.Model) {
return
}
start := time.Now()
resp, meta, err := h.gw.Rerank(c.Request.Context(), req, keyGroup(c))
entry := h.logEntry(c, "rerank", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "rerank", req.Model, false, req, resp)
c.JSON(http.StatusOK, resp)
}
// moderations 是 /ai/v1/moderations 端点(OpenAI 外壳映射 OCI Guardrails)。
//
// @Summary 内容审核端点(OCI Guardrails)
// @Tags AI 网关
// @Param body body aiwire.ModerationsRequest true "审核请求体(input 为字符串或字符串数组,单次至多 8 条;model 接受但忽略)"
// @Success 200 {object} aiwire.ModerationsResponse "审核结果(categories/category_scores 为 overall/blocklist/prompt_injection,pii 为扩展字段)"
// @Router /ai/v1/moderations [post]
func (h *aiGatewayHandler) moderations(c *gin.Context) {
var req aiwire.ModerationsRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
inputs, err := service.ModerationInputs(req.Input)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
start := time.Now()
resp, meta, err := h.gw.Moderations(c.Request.Context(), aiRandID("modr_"), inputs, keyGroup(c))
entry := h.logEntry(c, "moderations", "oci-guardrails", false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "moderations", "oci-guardrails", false, req, resp)
c.JSON(http.StatusOK, resp)
}
+230
View File
@@ -0,0 +1,230 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
t.Helper()
ch := &model.AiChannel{Name: "t-" + name, OciConfigID: 1, Region: "r-" + name, Enabled: true, Priority: 1, Weight: 1}
if err := db.Create(ch).Error; err != nil {
t.Fatalf("seed channel: %v", err)
}
cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1.." + name, Name: name, Vendor: "v", SyncedAt: time.Now()}
if err := db.Create(cache).Error; err != nil {
t.Fatalf("seed model cache: %v", err)
}
}
func TestAiGatewayKeyModelRestrict(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
// 受限密钥:白名单含脏输入,入库应规范化为 2 项(cohere + ghost)
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
`{"name":"limited","value":"limited-key-1234","models":[" cohere.command-a-03-2025 ","cohere.command-a-03-2025","","ghost-model"]}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建受限密钥 status = %d, body %s", w.Code, w.Body.String())
}
var created struct {
Item model.AiKey `json:"item"`
}
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("decode: %v", err)
}
if len(created.Item.Models) != 2 || created.Item.Models[0] != "cohere.command-a-03-2025" {
t.Fatalf("创建后 models 未规范化: %v", created.Item.Models)
}
// 不限密钥对照
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建不限密钥 status = %d, body %s", w.Code, w.Body.String())
}
seedGatewayModel(t, db, "cohere.command-a-03-2025")
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
deny := []string{"model_not_found", "无权访问"}
pass := []string{"未知模型"} // 穿过白名单闸门,由编排层以「未知模型」拒绝(池内无 ghost-model)
tests := []struct {
name string
key string
path string
body string
wantCode int
wantSub []string
}{
{"responses 流式同样拦截", "limited-key-1234", "/ai/v1/responses",
`{"model":"meta.llama-3.3-70b-instruct","stream":true,"input":"hi"}`, 404, deny},
{"responses 白名单内穿透", "limited-key-1234", "/ai/v1/responses",
`{"model":"ghost-model","input":"hi"}`, 404, pass},
{"embeddings 白名单外拦截", "limited-key-1234", "/ai/v1/embeddings",
`{"model":"meta.llama-3.3-70b-instruct","input":["hi"]}`, 404, deny},
{"messages 白名单外拦截为 Anthropic 体", "limited-key-1234", "/ai/v1/messages",
`{"model":"meta.llama-3.3-70b-instruct","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, 404,
[]string{`"type":"error"`, "model_not_found", "无权访问"}},
{"responses 白名单外拦截", "limited-key-1234", "/ai/v1/responses",
`{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny},
{"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses",
`{"model":"ghost-model","input":"hi"}`, 404, pass},
{"chat completions 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions",
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
{"messages 缺 max_tokens 按默认值放行", "open-key-12345", "/ai/v1/messages",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`,
400, []string{"仅支持 function"}},
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
400, []string{"invalid_request_error"}},
{"audio speech 白名单外拦截", "limited-key-1234", "/ai/v1/audio/speech",
`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`, 404, deny},
{"rerank 白名单外拦截", "limited-key-1234", "/ai/v1/rerank",
`{"model":"meta.llama-3.3-70b-instruct","query":"q","documents":["d"]}`, 404, deny},
{"rerank 缺 documents 拒绝", "open-key-12345", "/ai/v1/rerank",
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
`{"input":[]}`, 400, []string{"invalid_request_error"}},
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPost, tt.path, tt.key, tt.body)
if w.Code != tt.wantCode {
t.Fatalf("status = %d, want %d, body %s", w.Code, tt.wantCode, w.Body.String())
}
for _, sub := range tt.wantSub {
if !strings.Contains(w.Body.String(), sub) {
t.Errorf("body 缺少 %q: %s", sub, w.Body.String())
}
}
})
}
}
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
seedGatewayModel(t, db, "cohere.command-a-03-2025")
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
`{"name":"limited","value":"limited-key-1234","models":["cohere.command-a-03-2025"]}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建密钥 status = %d", w.Code)
}
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建密钥 status = %d", w.Code)
}
tests := []struct {
name string
key string
want []string
notWant []string
}{
{"受限密钥仅见交集", "limited-key-1234", []string{"cohere.command-a-03-2025"}, []string{"meta.llama-3.3-70b-instruct"}},
{"不限密钥全量可见", "open-key-12345", []string{"cohere.command-a-03-2025", "meta.llama-3.3-70b-instruct"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodGet, "/ai/v1/models", tt.key, "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", w.Code, w.Body.String())
}
for _, sub := range tt.want {
if !strings.Contains(w.Body.String(), sub) {
t.Errorf("应包含 %q: %s", sub, w.Body.String())
}
}
for _, sub := range tt.notWant {
if strings.Contains(w.Body.String(), sub) {
t.Errorf("不应包含 %q: %s", sub, w.Body.String())
}
}
})
}
}
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
r, auth, _, _ := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"k","value":"round-key-1234"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建 status = %d", w.Code)
}
var created struct {
Item model.AiKey `json:"item"`
}
_ = json.Unmarshal(w.Body.Bytes(), &created)
id := created.Item.ID
listModels := func() []string {
w := doRequest(t, r, http.MethodGet, "/api/v1/ai-keys", token, "")
var resp struct {
Items []model.AiKey `json:"items"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, k := range resp.Items {
if k.ID == id {
return k.Models
}
}
t.Fatalf("密钥 %d 不在列表中", id)
return nil
}
tests := []struct {
name string
body string
want []string
}{
{"设置白名单", `{"models":[" a-model ","a-model","b-model"]}`, []string{"a-model", "b-model"}},
{"不传 models 保持不变", `{"name":"k2"}`, []string{"a-model", "b-model"}},
{"空数组清空恢复不限", `{"models":[]}`, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPut, "/api/v1/ai-keys/"+strconv.Itoa(int(id)), token, tt.body)
if w.Code != http.StatusNoContent {
t.Fatalf("update status = %d, body %s", w.Code, w.Body.String())
}
got := listModels()
if len(got) != len(tt.want) {
t.Fatalf("models = %v, want %v", got, tt.want)
}
for i := range tt.want {
if got[i] != tt.want[i] {
t.Fatalf("models = %v, want %v", got, tt.want)
}
}
})
}
}
+39 -12
View File
@@ -10,12 +10,13 @@ import (
// ---- 实例 IP ----
// @Summary ---- 实例 IP ----
// @Summary 更换实例主网卡临时公网 IP
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} publicIpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip [post]
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
@@ -35,12 +36,38 @@ func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"publicIp": ip})
}
// @Summary 更换 VNIC 临时公网 IP
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param vnicId path string true "VNIC OCID"
// @Param body body object true "请求体:region"
// @Success 200 {object} publicIpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip [post]
func (h *ociConfigHandler) changeVnicPublicIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
}
_ = c.ShouldBindJSON(&req)
ip, err := h.svc.ChangeVnicPublicIP(c.Request.Context(), id, req.Region, c.Param("vnicId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"publicIp": ip})
}
// @Summary 实例添加 IPv6 地址
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any
// @Success 201 {object} ipv6AddressResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/ipv6-addresses [post]
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
@@ -92,7 +119,7 @@ type attachVnicRequest struct {
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Vnic
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [get]
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
@@ -113,7 +140,7 @@ func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body attachVnicRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.Vnic
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [post]
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
@@ -160,7 +187,7 @@ func (h *ociConfigHandler) detachVnic(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param vnicId path string true "vnicId"
// @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any
// @Success 201 {object} addressResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses [post]
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
@@ -195,7 +222,7 @@ type attachBootVolumeRequest struct {
// @Tags 存储
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.BootVolumeAttachment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [get]
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
@@ -216,7 +243,7 @@ func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body attachBootVolumeRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.BootVolumeAttachment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [post]
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
@@ -242,7 +269,7 @@ func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body attachBootVolumeRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.BootVolumeAttachment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/replace-boot-volume [post]
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
@@ -293,7 +320,7 @@ type attachVolumeRequest struct {
// @Summary 块存储卷列表
// @Tags 存储
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.BlockVolume
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/volumes [get]
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
@@ -313,7 +340,7 @@ func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
// @Tags 存储
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.VolumeAttachment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [get]
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
@@ -334,7 +361,7 @@ func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body attachVolumeRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.VolumeAttachment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [post]
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
+3 -3
View File
@@ -32,9 +32,9 @@ type loginRequest struct {
// @Accept json
// @Produce json
// @Param body body loginRequest true "登录凭据"
// @Success 200 {object} map[string]any "token 与 expiresAt"
// @Failure 401 {object} map[string]string "凭据错误"
// @Failure 428 {object} map[string]any "需要两步验证码(totpRequired=true)"
// @Success 200 {object} tokenResponse "token 与 expiresAt"
// @Failure 401 {object} errorResponse "凭据错误"
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
// @Router /api/v1/auth/login [post]
func (h *authHandler) login(c *gin.Context) {
var req loginRequest
+37 -12
View File
@@ -5,9 +5,12 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
@@ -15,6 +18,7 @@ import (
type authxHandler struct {
auth *service.AuthService
oauth *service.OAuthService
logs *service.SystemLogService
}
// ---- TOTP(JWT 组内) ----
@@ -23,7 +27,7 @@ type authxHandler struct {
//
// @Summary 两步验证状态
// @Tags 认证
// @Success 200 {object} map[string]bool "enabled"
// @Success 200 {object} enabledResponse "enabled"
// @Security BearerAuth
// @Router /api/v1/auth/totp [get]
func (h *authxHandler) totpStatus(c *gin.Context) {
@@ -39,8 +43,8 @@ func (h *authxHandler) totpStatus(c *gin.Context) {
//
// @Summary 发起两步验证设置
// @Tags 认证
// @Success 200 {object} map[string]string "secret 与 otpauthUri"
// @Failure 409 {object} map[string]string "已启用"
// @Success 200 {object} totpSetupResponse "secret 与 otpauthUri"
// @Failure 409 {object} errorResponse "已启用"
// @Security BearerAuth
// @Router /api/v1/auth/totp/setup [post]
func (h *authxHandler) totpSetup(c *gin.Context) {
@@ -128,7 +132,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
//
// @Summary 撤销全部会话
// @Tags 认证
// @Success 200 {object} map[string]string "新 token 与 expiresAt"
// @Success 200 {object} tokenResponse "新 token 与 expiresAt"
// @Security BearerAuth
// @Router /api/v1/auth/revoke-sessions [post]
func (h *authxHandler) revokeSessions(c *gin.Context) {
@@ -146,7 +150,7 @@ func (h *authxHandler) revokeSessions(c *gin.Context) {
//
// @Summary 登录凭据摘要
// @Tags 认证
// @Success 200 {object} map[string]any "username 与 passwordLoginDisabled"
// @Success 200 {object} credentialsResponse "username 与 passwordLoginDisabled"
// @Security BearerAuth
// @Router /api/v1/auth/credentials [get]
func (h *authxHandler) getCredentials(c *gin.Context) {
@@ -167,7 +171,7 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
// @Tags 认证
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
// @Success 204 "已更新,请重新登录"
// @Failure 401 {object} map[string]string "当前密码错误"
// @Failure 401 {object} errorResponse "当前密码错误"
// @Security BearerAuth
// @Router /api/v1/auth/credentials [put]
func (h *authxHandler) updateCredentials(c *gin.Context) {
@@ -199,7 +203,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
// @Tags 认证
// @Param body body object true "{disabled: bool}"
// @Success 204 "已保存"
// @Failure 409 {object} map[string]string "未绑定外部身份"
// @Failure 409 {object} errorResponse "未绑定外部身份"
// @Security BearerAuth
// @Router /api/v1/auth/password-login [put]
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
@@ -230,7 +234,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
//
// @Summary 外部登录 provider 列表
// @Tags 认证
// @Success 200 {object} map[string]any "providers 与 passwordLoginDisabled"
// @Success 200 {object} oauthProvidersResponse "providers 与 passwordLoginDisabled"
// @Router /api/v1/auth/oauth/providers [get]
func (h *authxHandler) oauthProviders(c *gin.Context) {
providers := h.oauth.Providers(c.Request.Context())
@@ -247,7 +251,7 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
// @Tags 认证
// @Param provider path string true "oidc / github"
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
// @Success 200 {object} map[string]string "url"
// @Success 200 {object} urlResponse "url"
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
provider := c.Param("provider")
@@ -299,10 +303,12 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
// @Router /api/v1/auth/oauth/{provider}/callback [get]
func (h *authxHandler) oauthCallback(c *gin.Context) {
start := time.Now()
provider := c.Param("provider")
token, _, mode, err := h.oauth.HandleCallback(
token, username, mode, err := h.oauth.HandleCallback(
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
if err != nil {
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
target := "/login"
if mode == "bind" {
target = "/settings"
@@ -310,6 +316,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
return
}
h.recordOauth(c, username, http.StatusOK, "", start)
if mode == "bind" {
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
@@ -319,6 +326,24 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
}
// recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET,
// 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。
func (h *authxHandler) recordOauth(c *gin.Context, username string, status int, errMsg string, start time.Time) {
if h.logs == nil {
return
}
h.logs.Record(model.SystemLog{
Username: username,
Method: c.Request.Method,
Path: requestPath(c),
Status: status,
DurationMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
ErrMsg: errMsg,
})
}
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
func oauthErrText(err error) string {
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
@@ -333,7 +358,7 @@ func oauthErrText(err error) string {
//
// @Summary 已绑定外部身份
// @Tags 认证
// @Success 200 {object} map[string]any "items"
// @Success 200 {object} itemsResponse[model.UserIdentity] "items"
// @Security BearerAuth
// @Router /api/v1/auth/identities [get]
func (h *authxHandler) identities(c *gin.Context) {
@@ -351,7 +376,7 @@ func (h *authxHandler) identities(c *gin.Context) {
// @Tags 认证
// @Param id path int true "身份 ID"
// @Success 204 "已解绑"
// @Failure 409 {object} map[string]string "最后一个身份不可解绑"
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
// @Security BearerAuth
// @Router /api/v1/auth/identities/{id} [delete]
func (h *authxHandler) unbindIdentity(c *gin.Context) {
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/oci" // swagger 注解引用
)
// payInvoiceRequest 是付款请求体;email 接收 OSP 付款回执。
type payInvoiceRequest struct {
Email string `json:"email" binding:"required"`
}
// @Summary 发票列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param year query int false "自然年过滤(按开票时间);缺省全量"
// @Success 200 {array} oci.Invoice
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices [get]
func (h *ociConfigHandler) invoices(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
year, _ := strconv.Atoi(c.Query("year"))
list, err := h.svc.Invoices(c.Request.Context(), id, year)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, list)
}
// @Summary 发票费用明细
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Success 200 {array} oci.InvoiceLine
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/lines [get]
func (h *ociConfigHandler) invoiceLines(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
lines, err := h.svc.InvoiceLines(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, lines)
}
// @Summary 发票 PDF 下载
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param number query string false "发票号(用作下载文件名)"
// @Success 200 {file} binary "PDF 原文"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pdf [get]
func (h *ociConfigHandler) invoicePdf(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
data, err := h.svc.InvoicePdf(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", pdfFileName(c.Query("number"), c.Param("internalId"))))
c.Data(http.StatusOK, "application/pdf", data)
}
// pdfFileName 生成下载文件名:优先发票号,剔除引号/路径分隔等不安全字符。
func pdfFileName(number, internalID string) string {
name := strings.TrimSpace(number)
if name == "" {
name = internalID
}
name = strings.Map(func(r rune) rune {
if r == '"' || r == '/' || r == '\\' || r < 0x20 {
return '_'
}
return r
}, name)
return name + ".pdf"
}
// @Summary 支付发票
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param body body payInvoiceRequest true "回执邮箱"
// @Success 200 {object} map[string]string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pay [post]
func (h *ociConfigHandler) payInvoice(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req payInvoiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.PayInvoice(c.Request.Context(), id, c.Param("internalId"), req.Email); err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"status": "submitted"})
}
// @Summary 付款方式列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Success 200 {array} oci.PaymentMethod
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/payment-methods [get]
func (h *ociConfigHandler) paymentMethods(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
methods, err := h.svc.PaymentMethods(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, methods)
}
+4 -2
View File
@@ -4,6 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/oci" // swagger 注解引用
)
// ---- 控制台连接(VNC / 串口) ----
@@ -18,7 +20,7 @@ type createConsoleConnectionRequest struct {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body createConsoleConnectionRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.ConsoleConnection
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [post]
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
@@ -43,7 +45,7 @@ func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.ConsoleConnection
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [get]
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
+6 -6
View File
@@ -41,7 +41,7 @@ func boolOr(v *bool, def bool) bool {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.IdentityProviderInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
@@ -62,7 +62,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createIdpRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.IdentityProviderInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
@@ -102,7 +102,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param idpId path string true "idpId"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.IdentityProviderInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post]
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
@@ -149,7 +149,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {file} file "SAML 元数据 XML(附件下载)"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
@@ -170,7 +170,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.SignOnRuleInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
@@ -191,7 +191,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.SignOnRuleInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post]
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
+11 -9
View File
@@ -11,7 +11,7 @@ import (
// @Summary 可用域列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/availability-domains [get]
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
@@ -44,6 +44,7 @@ type createInstanceRequest struct {
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
AssignPublicIP bool `json:"assignPublicIp"`
ReservedPublicIPID string `json:"reservedPublicIpId"` // 非空时不分配临时 IP,实例就绪后自动绑定该保留 IP
AssignIpv6 bool `json:"assignIpv6"`
SSHPublicKey string `json:"sshPublicKey"`
RootPassword string `json:"rootPassword"`
@@ -67,7 +68,7 @@ type instanceActionRequest struct {
// @Summary 实例列表
// @Tags 计算
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Instance
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances [get]
func (h *ociConfigHandler) listInstances(c *gin.Context) {
@@ -87,7 +88,7 @@ func (h *ociConfigHandler) listInstances(c *gin.Context) {
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param body body createInstanceRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} createInstancesResponse "部分成功仍 201,errors 为逐台失败信息"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances [post]
func (h *ociConfigHandler) createInstance(c *gin.Context) {
@@ -118,6 +119,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
SubnetID: req.SubnetID,
AssignPublicIP: req.AssignPublicIP,
ReservedPublicIPID: req.ReservedPublicIPID,
AssignIpv6: req.AssignIpv6,
SSHPublicKey: req.SSHPublicKey,
RootPassword: req.RootPassword,
@@ -147,7 +149,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Instance
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [get]
func (h *ociConfigHandler) getInstance(c *gin.Context) {
@@ -168,7 +170,7 @@ func (h *ociConfigHandler) getInstance(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body updateInstanceRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Instance
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [put]
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
@@ -220,7 +222,7 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body instanceActionRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Instance
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
@@ -253,7 +255,7 @@ type updateBootVolumeRequest struct {
// @Summary 引导卷列表
// @Tags 存储
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.BootVolume
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/boot-volumes [get]
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
@@ -273,7 +275,7 @@ func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
// @Tags 存储
// @Param id path int true "配置 ID"
// @Param bootVolumeId path string true "bootVolumeId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.BootVolume
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [get]
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
@@ -294,7 +296,7 @@ func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param bootVolumeId path string true "bootVolumeId"
// @Param body body updateBootVolumeRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.BootVolume
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [put]
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
+7 -127
View File
@@ -7,7 +7,8 @@ import (
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service"
)
@@ -21,7 +22,7 @@ type logEventHandler struct {
// @Summary 查询配置的回调地址
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} logWebhookStatusResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-webhook [get]
func (h *logEventHandler) getWebhook(c *gin.Context) {
@@ -46,7 +47,7 @@ func (h *logEventHandler) getWebhook(c *gin.Context) {
// @Summary 生成
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.LogWebhookInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-webhook [post]
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
@@ -88,7 +89,7 @@ func (h *logEventHandler) revokeWebhook(c *gin.Context) {
// @Tags 任务与日志回传
// @Param configId query int false "按配置过滤"
// @Param q query string false "关键字"
// @Success 200 {object} map[string]any "items 与 total"
// @Success 200 {object} pagedResponse[model.LogEvent] "items 与 total"
// @Security BearerAuth
// @Router /api/v1/log-events [get]
func (h *logEventHandler) list(c *gin.Context) {
@@ -107,133 +108,12 @@ func (h *logEventHandler) list(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
// alertRuleRequest 是创建/更新告警规则的请求体。
type alertRuleRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"`
EventTypes string `json:"eventTypes"`
SourceIPs string `json:"sourceIps"`
SourceIPMode string `json:"sourceIpMode"`
ResourceMatch string `json:"resourceMatch"`
Threshold int `json:"threshold"`
WindowMinutes int `json:"windowMinutes"`
}
// toModel 转为规则模型(默认阈值 1)。
func (r alertRuleRequest) toModel() model.AlertRule {
if r.Threshold == 0 {
r.Threshold = 1
}
return model.AlertRule{
Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID,
EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode,
ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes,
}
}
// listAlertRules 返回全部告警规则。
//
// @Summary 告警规则列表
// @Tags 任务与日志回传
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [get]
func (h *logEventHandler) listAlertRules(c *gin.Context) {
rules, err := h.svc.ListAlertRules(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": rules})
}
// createAlertRule 创建告警规则;字段非法返回 400。
//
// @Summary 创建告警规则
// @Tags 任务与日志回传
// @Param body body alertRuleRequest true "请求体"
// @Success 201 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [post]
func (h *logEventHandler) createAlertRule(c *gin.Context) {
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusCreated, rule)
}
// updateAlertRule 整体覆盖告警规则(含启停)。
//
// @Summary 更新告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Param body body alertRuleRequest true "请求体"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [put]
func (h *logEventHandler) updateAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusOK, rule)
}
// deleteAlertRule 删除告警规则及其命中记录。
//
// @Summary 删除告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [delete]
func (h *logEventHandler) deleteAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// respondAlertRuleError 把字段校验错误映射为 400。
func respondAlertRuleError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidAlertRule) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
respondError(c, err)
}
// getRelay 查询 OCI 侧链路状态与关键事件清单。
//
// @Summary 查询 OCI 侧链路状态与关键事件清单
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.RelayView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-relay [get]
func (h *logEventHandler) getRelay(c *gin.Context) {
@@ -254,7 +134,7 @@ func (h *logEventHandler) getRelay(c *gin.Context) {
// @Summary 一键建立回传链路
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.RelayView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-relay [post]
func (h *logEventHandler) setupRelay(c *gin.Context) {
+16 -16
View File
@@ -11,7 +11,7 @@ import (
// @Summary 实例形状列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.ComputeShape
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/shapes [get]
func (h *ociConfigHandler) shapes(c *gin.Context) {
@@ -30,7 +30,7 @@ func (h *ociConfigHandler) shapes(c *gin.Context) {
// @Summary 镜像列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Image
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/images [get]
func (h *ociConfigHandler) images(c *gin.Context) {
@@ -54,7 +54,7 @@ func (h *ociConfigHandler) images(c *gin.Context) {
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Param imageId path string true "imageId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Image
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/images/{imageId} [get]
func (h *ociConfigHandler) getImage(c *gin.Context) {
@@ -89,7 +89,7 @@ type renameRequest struct {
// @Summary VCN 列表
// @Tags 网络
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.VCN
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vcns [get]
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
@@ -109,7 +109,7 @@ func (h *ociConfigHandler) listVCNs(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body createVCNRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.VCN
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vcns [post]
func (h *ociConfigHandler) createVCN(c *gin.Context) {
@@ -141,7 +141,7 @@ func (h *ociConfigHandler) createVCN(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param vcnId path string true "vcnId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.VCN
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [get]
func (h *ociConfigHandler) getVCN(c *gin.Context) {
@@ -162,7 +162,7 @@ func (h *ociConfigHandler) getVCN(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param vcnId path string true "vcnId"
// @Param body body renameRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.VCN
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [put]
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
@@ -211,7 +211,7 @@ type enableIPv6Request struct {
// @Param id path int true "配置 ID"
// @Param vcnId path string true "vcnId"
// @Param body body enableIPv6Request true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} ipv6StepsResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId}/enable-ipv6 [post]
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
@@ -247,7 +247,7 @@ type createSubnetRequest struct {
// @Summary 子网列表
// @Tags 网络
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Subnet
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subnets [get]
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
@@ -267,7 +267,7 @@ func (h *ociConfigHandler) listSubnets(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body createSubnetRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.Subnet
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subnets [post]
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
@@ -300,7 +300,7 @@ func (h *ociConfigHandler) createSubnet(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param subnetId path string true "subnetId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Subnet
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [get]
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
@@ -321,7 +321,7 @@ func (h *ociConfigHandler) getSubnet(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param subnetId path string true "subnetId"
// @Param body body renameRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.Subnet
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [put]
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
@@ -381,7 +381,7 @@ type updateSecurityListRequest struct {
// @Summary 安全列表清单
// @Tags 网络
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.SecurityList
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/security-lists [get]
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
@@ -401,7 +401,7 @@ func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body createSecurityListRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.SecurityList
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/security-lists [post]
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
@@ -432,7 +432,7 @@ func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param securityListId path string true "securityListId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.SecurityList
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [get]
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
@@ -453,7 +453,7 @@ func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param securityListId path string true "securityListId"
// @Param body body updateSecurityListRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.SecurityList
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [put]
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
+450
View File
@@ -0,0 +1,450 @@
package api
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
)
// ---- 对象存储 ----
// @Summary 对象存储 namespace
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param region query string false "区域"
// @Success 200 {object} map[string]string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/object-storage/namespace [get]
func (h *ociConfigHandler) osNamespace(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
ns, err := h.svc.ObjectStorageNamespace(c.Request.Context(), id, c.Query("region"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"namespace": ns})
}
// @Summary 存储桶列表
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param region query string false "区域"
// @Param compartmentId query string false "区间 OCID,空为生效区间"
// @Success 200 {array} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets [get]
func (h *ociConfigHandler) listBuckets(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
buckets, err := h.svc.Buckets(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, buckets)
}
type createBucketRequest struct {
Region string `json:"region"`
CompartmentID string `json:"compartmentId"`
Name string `json:"name" binding:"required"`
PublicRead bool `json:"publicRead"`
StorageTier string `json:"storageTier"` // Standard / Archive
VersioningOn bool `json:"versioningOn"`
}
// @Summary 创建存储桶
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param body body createBucketRequest true "请求体"
// @Success 201 {object} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets [post]
func (h *ociConfigHandler) createBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req createBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bucket, err := h.svc.CreateBucket(c.Request.Context(), id, req.Region, oci.CreateBucketInput{
Name: req.Name, CompartmentID: req.CompartmentID,
PublicRead: req.PublicRead, StorageTier: req.StorageTier, VersioningOn: req.VersioningOn,
})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, bucket)
}
type updateBucketRequest struct {
Region string `json:"region"`
PublicRead *bool `json:"publicRead"`
VersioningOn *bool `json:"versioningOn"`
}
// @Summary 更新存储桶(可见性/版本控制)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body updateBucketRequest true "请求体"
// @Success 200 {object} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [put]
func (h *ociConfigHandler) updateBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req updateBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bucket, err := h.svc.UpdateBucket(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.UpdateBucketInput{
PublicRead: req.PublicRead, VersioningOn: req.VersioningOn,
})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, bucket)
}
// @Summary 删除存储桶(非空桶后台清空后删除)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Success 200 {object} map[string]bool "queued=true 表示已转后台清空删除"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [delete]
func (h *ociConfigHandler) deleteBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
queued, err := h.svc.DeleteBucket(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"queued": queued})
}
// @Summary 对象列表(前缀模式分页)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param prefix query string false "前缀(虚拟目录)"
// @Param startWith query string false "上页 nextStartWith 游标"
// @Param limit query int false "每页数量,默认 100"
// @Success 200 {object} oci.ListObjectsResult
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [get]
func (h *ociConfigHandler) listObjects(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
result, err := h.svc.Objects(c.Request.Context(), id, c.Query("region"),
c.Param("bucket"), c.Query("prefix"), c.Query("startWith"), limit)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
// @Summary 删除对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 204 "删除成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [delete]
func (h *ociConfigHandler) deleteObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteObject(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 重命名对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body object true "请求体:region、source、newName"
// @Success 204 "重命名成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename [post]
func (h *ociConfigHandler) renameObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
Source string `json:"source" binding:"required"`
NewName string `json:"newName" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.RenameObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Source, req.NewName)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 取回 Archive 对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body object true "请求体:region、object、hours(可下载时长,默认 24)"
// @Success 202 "取回已提交,约 1 小时后可下载"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore [post]
func (h *ociConfigHandler) restoreObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
Object string `json:"object" binding:"required"`
Hours int `json:"hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.RestoreObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Object, req.Hours)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusAccepted)
}
// @Summary 对象元数据
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {object} oci.ObjectDetail
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail [get]
func (h *ociConfigHandler) objectDetail(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
detail, err := h.svc.ObjectDetail(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, detail)
}
// maxPutContentBody 是保存对象内容的请求体上限(与 service 层 5MB 限制一致)。
const maxPutContentBody = 5 << 20
// respondContentError 对象内容中转专属错误:超限映射 413,其余走通用处理。
func respondContentError(c *gin.Context, err error) {
if errors.Is(err, oci.ErrObjectTooLarge) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "对象超出面板中转大小上限"})
return
}
respondError(c, err)
}
// @Summary 读取对象内容(面板中转)
// @Description 预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {string} string "对象原始内容"
// @Failure 413 {object} map[string]string "对象超出中转上限"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [get]
func (h *ociConfigHandler) getObjectContent(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
content, err := h.svc.ObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondContentError(c, err)
return
}
ct := content.ContentType
if ct == "" {
ct = "application/octet-stream"
}
c.Header("ETag", content.Etag)
c.Data(http.StatusOK, ct, content.Data)
}
// @Summary 保存对象内容(面板中转)
// @Description 文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {object} map[string]string "etag=新 ETag"
// @Failure 412 {object} map[string]string "If-Match 不匹配,对象已被并发修改"
// @Failure 413 {object} map[string]string "请求体超出上限"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [put]
func (h *ociConfigHandler) putObjectContent(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxPutContentBody)
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "请求体超出 5MB 上限"})
return
}
etag, err := h.svc.PutObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"),
c.Query("object"), data, c.ContentType(), c.GetHeader("If-Match"))
if err != nil {
respondContentError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"etag": etag})
}
type createPARRequest struct {
Region string `json:"region"`
Name string `json:"name"`
ObjectName string `json:"objectName"` // 空 = 桶级
AccessType string `json:"accessType" binding:"required"`
ExpiresHours int `json:"expiresHours"`
}
// @Summary 签发临时链接(PAR)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body createPARRequest true "请求体"
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
func (h *ociConfigHandler) createPAR(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req createPARRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
par, err := h.svc.CreatePAR(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.CreatePARInput{
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, par)
}
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
type parPage struct {
Items []oci.PAR `json:"items"`
NextPage string `json:"nextPage"`
}
// @Summary 临时链接列表
// @Description 游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param page query string false "分页游标(上次响应的 nextPage,空取首页)"
// @Param limit query int false "每页条数,默认 100,上限 1000"
// @Success 200 {object} parPage
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [get]
func (h *ociConfigHandler) listPARs(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
items, next, err := h.svc.PARsPage(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("page"), limit)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, parPage{Items: items, NextPage: next})
}
// @Summary 删除临时链接
// @Description all=1 时删除桶内全部 PAR 并返回 {"deleted": n};否则按 parId 删单条
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
// @Param all query string false "为 1 时删除全部"
// @Param region query string false "区域"
// @Success 204 "删除成功,链接立即失效"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
if c.Query("all") == "1" {
n, err := h.svc.DeleteAllPARs(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
return
}
err := h.svc.DeletePAR(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("parId"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+10 -8
View File
@@ -9,8 +9,10 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/oracle/oci-go-sdk/v65/common"
"gorm.io/gorm"
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/oci"
"oci-portal/internal/service"
@@ -39,7 +41,7 @@ type importRequest struct {
// @Summary 导入租户配置
// @Tags 租户配置
// @Param body body importRequest true "API Key 配置(私钥密文落库)"
// @Success 201 {object} map[string]any
// @Success 201 {object} model.OciConfig
// @Security BearerAuth
// @Router /api/v1/oci-configs [post]
func (h *ociConfigHandler) create(c *gin.Context) {
@@ -71,7 +73,7 @@ func (h *ociConfigHandler) create(c *gin.Context) {
// @Summary 租户配置列表
// @Tags 租户配置
// @Success 200 {object} map[string]any "items"
// @Success 200 {array} service.ConfigSummary
// @Security BearerAuth
// @Router /api/v1/oci-configs [get]
func (h *ociConfigHandler) list(c *gin.Context) {
@@ -86,7 +88,7 @@ func (h *ociConfigHandler) list(c *gin.Context) {
// @Summary 租户配置详情
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} model.OciConfig
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id} [get]
func (h *ociConfigHandler) get(c *gin.Context) {
@@ -105,7 +107,7 @@ func (h *ociConfigHandler) get(c *gin.Context) {
// @Summary 验证配置连通性并刷新画像
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/verify [post]
func (h *ociConfigHandler) verify(c *gin.Context) {
@@ -139,7 +141,7 @@ type updateConfigRequest struct {
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)"
// @Success 200 {object} map[string]any
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id} [put]
func (h *ociConfigHandler) update(c *gin.Context) {
@@ -194,7 +196,7 @@ func (h *ociConfigHandler) remove(c *gin.Context) {
// @Summary 列出租户下全部 ACTIVE compartment
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Compartment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/compartments [get]
func (h *ociConfigHandler) compartments(c *gin.Context) {
@@ -216,7 +218,7 @@ func (h *ociConfigHandler) compartments(c *gin.Context) {
// @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} service.RegionSubscriptionView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/cached-regions [get]
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
@@ -237,7 +239,7 @@ func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
// @Summary 返回筛选器用区间列表:未开多区间支持返回空数组
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.Compartment
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/cached-compartments [get]
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
+3 -1
View File
@@ -4,13 +4,15 @@ import (
"net/http"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/service" // swagger 注解引用
)
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
//
// @Summary 返回总览页聚合数据
// @Tags 租户配置
// @Success 200 {object} map[string]any
// @Success 200 {object} service.Overview
// @Security BearerAuth
// @Router /api/v1/overview [get]
func (h *ociConfigHandler) overview(c *gin.Context) {
+34 -5
View File
@@ -18,7 +18,7 @@ type proxyHandler struct {
//
// @Summary 代理列表
// @Tags 设置
// @Success 200 {object} map[string]any "items"
// @Success 200 {object} itemsResponse[service.ProxyView] "items"
// @Security BearerAuth
// @Router /api/v1/proxies [get]
func (h *proxyHandler) list(c *gin.Context) {
@@ -35,7 +35,7 @@ func (h *proxyHandler) list(c *gin.Context) {
// @Summary 创建代理
// @Tags 设置
// @Param body body service.ProxyInput true "代理配置(密码只写不回)"
// @Success 201 {object} map[string]any
// @Success 201 {object} service.ProxyView
// @Security BearerAuth
// @Router /api/v1/proxies [post]
func (h *proxyHandler) create(c *gin.Context) {
@@ -58,7 +58,7 @@ func (h *proxyHandler) create(c *gin.Context) {
// @Tags 设置
// @Param id path int true "代理 ID"
// @Param body body service.ProxyInput true "代理配置(密码缺省沿用)"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.ProxyView
// @Security BearerAuth
// @Router /api/v1/proxies/{id} [put]
func (h *proxyHandler) update(c *gin.Context) {
@@ -104,7 +104,7 @@ func (h *proxyHandler) remove(c *gin.Context) {
// @Summary 批量导入代理
// @Tags 设置
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.ImportResult
// @Security BearerAuth
// @Router /api/v1/proxies/import [post]
func (h *proxyHandler) importBatch(c *gin.Context) {
@@ -128,7 +128,7 @@ func (h *proxyHandler) importBatch(c *gin.Context) {
// @Summary 手动重测代理出口地区,同步返回最新视图
// @Tags 设置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.ProxyView
// @Security BearerAuth
// @Router /api/v1/proxies/{id}/probe [post]
func (h *proxyHandler) probe(c *gin.Context) {
@@ -144,6 +144,35 @@ func (h *proxyHandler) probe(c *gin.Context) {
c.JSON(http.StatusOK, view)
}
// setTenants 设置代理关联的租户全集(代理侧批量关联 / 解除)。
//
// @Summary 设置代理关联的租户全集
// @Tags 设置
// @Param id path int true "代理 ID"
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
// @Success 200 {object} usedByResponse "usedBy"
// @Security BearerAuth
// @Router /api/v1/proxies/{id}/tenants [put]
func (h *proxyHandler) setTenants(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
OciConfigIDs []uint `json:"ociConfigIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
used, err := h.svc.SetTenants(c.Request.Context(), id, req.OciConfigIDs)
if err != nil {
h.respond(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"usedBy": used})
}
// respond 把代理业务错误映射到语义状态码。
func (h *proxyHandler) respond(c *gin.Context, err error) {
switch {
+6 -4
View File
@@ -6,6 +6,8 @@ import (
"github.com/gin-gonic/gin"
_ "oci-portal/internal/service" // swagger 注解引用
"oci-portal/internal/oci"
)
@@ -13,7 +15,7 @@ import (
//
// @Summary 返回本地维护的完整区域表
// @Tags 租户配置
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.RegionInfo
// @Security BearerAuth
// @Router /api/v1/regions [get]
func listRegions(c *gin.Context) {
@@ -28,7 +30,7 @@ func listRegions(c *gin.Context) {
// @Summary 区域订阅列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} service.RegionSubscriptionView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/region-subscriptions [get]
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
@@ -52,7 +54,7 @@ type subscribeRegionRequest struct {
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Param body body subscribeRegionRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {array} service.RegionSubscriptionView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/region-subscriptions [post]
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
@@ -76,7 +78,7 @@ func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
// @Summary 服务限额查询
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.LimitValue
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/limits [get]
func (h *ociConfigHandler) limits(c *gin.Context) {
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
)
// swag 解析 @Success 里的 oci.ReservedIP 需要本文件持有该包导入
var _ = oci.ReservedIP{}
// ---- 保留公网 IP ----
// @Summary 保留 IP 列表
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param region query string false "区域,空为主区域"
// @Param compartmentId query string false "区间 OCID,空为生效区间"
// @Success 200 {array} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [get]
func (h *ociConfigHandler) listReservedIPs(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
ips, err := h.svc.ReservedIPs(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, ips)
}
// @Summary 创建保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:region、compartmentId、displayName"
// @Success 201 {object} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [post]
func (h *ociConfigHandler) createReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
CompartmentID string `json:"compartmentId"`
DisplayName string `json:"displayName"`
}
_ = c.ShouldBindJSON(&req)
ip, err := h.svc.CreateReservedIP(c.Request.Context(), id, req.Region, req.CompartmentID, req.DisplayName)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, ip)
}
// @Summary 绑定/解绑保留 IP
// @Description vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param body body object true "请求体:region、instanceId、vnicId"
// @Success 204 "绑定成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [put]
func (h *ociConfigHandler) assignReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
InstanceID string `json:"instanceId"`
VnicID string `json:"vnicId"`
}
_ = c.ShouldBindJSON(&req)
err := h.svc.AssignReservedIP(c.Request.Context(), id, req.Region, c.Param("publicIpId"), req.InstanceID, req.VnicID)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 删除保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param region query string false "区域"
// @Success 204 "删除成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [delete]
func (h *ociConfigHandler) deleteReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteReservedIP(c.Request.Context(), id, c.Query("region"), c.Param("publicIpId"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+8 -1
View File
@@ -57,6 +57,13 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
}
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
t.Helper()
r, auth, systemLogs, _ := newTestRouterDB(t)
return r, auth, systemLogs
}
// newTestRouterDB 额外回传 db 句柄,供需要直插数据(如网关模型缓存)的测试使用。
func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
@@ -90,7 +97,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.Sy
logEvents := service.NewLogEventService(db)
oauth := service.NewOAuthService(db, settings, auth)
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
return r, auth, systemLogs
return r, auth, systemLogs, db
}
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
+12
View File
@@ -15,6 +15,10 @@ func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
ai.POST("/responses", aih.responses)
ai.POST("/messages", aih.messages)
ai.POST("/embeddings", aih.embeddings)
ai.POST("/audio/speech", aih.audioSpeech)
ai.POST("/tts", aih.tts)
ai.POST("/rerank", aih.rerank)
ai.POST("/moderations", aih.moderations)
ai.GET("/models", aih.listModels)
}
@@ -32,7 +36,15 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
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)
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
secured.GET("/ai-logs", aiadmin.listLogs)
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
}
+1 -1
View File
@@ -12,7 +12,7 @@ func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *s
v1.POST("/auth/login", ah.login)
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
ax := &authxHandler{auth: auth, oauth: oauth}
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
v1.GET("/auth/oauth/providers", ax.oauthProviders)
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
+34
View File
@@ -16,6 +16,7 @@ func registerOci(secured *gin.RouterGroup, ociConfigs *service.OciConfigService)
registerOciCompute(secured, h)
registerOciNetwork(secured, h)
registerOciStorage(secured, h)
registerOciObjectStorage(secured, h)
registerOciCost(secured, h)
registerOciTenantIAM(secured, h)
}
@@ -37,6 +38,13 @@ func registerOciConfig(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/limits/services", h.limitServices)
g.GET("/oci-configs/:id/subscriptions", h.subscriptions)
g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
// 账单:发票与付款方式(OSP Gateway,仅主区域)
g.GET("/oci-configs/:id/invoices", h.invoices)
g.GET("/oci-configs/:id/invoices/:internalId/lines", h.invoiceLines)
g.GET("/oci-configs/:id/invoices/:internalId/pdf", h.invoicePdf)
g.POST("/oci-configs/:id/invoices/:internalId/pay", h.payInvoice)
g.GET("/oci-configs/:id/payment-methods", h.paymentMethods)
g.GET("/oci-configs/:id/shapes", h.shapes)
g.GET("/oci-configs/:id/images", h.images)
g.GET("/oci-configs/:id/images/:imageId", h.getImage)
@@ -61,6 +69,7 @@ func registerOciCompute(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
g.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6)
g.POST("/oci-configs/:id/vnics/:vnicId/change-public-ip", h.changeVnicPublicIP)
g.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic)
}
@@ -77,6 +86,10 @@ func registerOciNetwork(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
g.GET("/oci-configs/:id/reserved-ips", h.listReservedIPs)
g.POST("/oci-configs/:id/reserved-ips", h.createReservedIP)
g.PUT("/oci-configs/:id/reserved-ips/:publicIpId", h.assignReservedIP)
g.DELETE("/oci-configs/:id/reserved-ips/:publicIpId", h.deleteReservedIP)
g.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
g.POST("/oci-configs/:id/security-lists", h.createSecurityList)
g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
@@ -100,6 +113,27 @@ func registerOciStorage(g *gin.RouterGroup, h *ociConfigHandler) {
g.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume)
}
// registerOciObjectStorage 对象存储:namespace、桶、对象、临时链接。
func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/object-storage/namespace", h.osNamespace)
g.GET("/oci-configs/:id/buckets", h.listBuckets)
g.POST("/oci-configs/:id/buckets", h.createBucket)
g.PUT("/oci-configs/:id/buckets/:bucket", h.updateBucket)
g.DELETE("/oci-configs/:id/buckets/:bucket", h.deleteBucket)
g.GET("/oci-configs/:id/buckets/:bucket/objects", h.listObjects)
g.DELETE("/oci-configs/:id/buckets/:bucket/objects", h.deleteObject)
g.POST("/oci-configs/:id/buckets/:bucket/objects/rename", h.renameObject)
g.POST("/oci-configs/:id/buckets/:bucket/objects/restore", h.restoreObject)
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
// 小文件中转:预览/编辑直读直写,不签发 PAR
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
}
// registerOciCost 成本快照。
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/costs", h.costs)
+1
View File
@@ -35,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.PUT("/proxies/:id", px.update)
secured.DELETE("/proxies/:id", px.remove)
secured.POST("/proxies/:id/probe", px.probe)
secured.PUT("/proxies/:id/tenants", px.setTenants)
}
-4
View File
@@ -19,10 +19,6 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService,
le := &logEventHandler{svc: logEvents}
secured.GET("/log-events", le.list)
secured.GET("/log-events/alert-rules", le.listAlertRules)
secured.POST("/log-events/alert-rules", le.createAlertRule)
secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule)
secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule)
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
+11 -11
View File
@@ -19,7 +19,7 @@ type settingsHandler struct {
//
// @Summary 返回脱敏后的 Telegram 配置,绝不回 token 明文
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} service.TelegramView
// @Security BearerAuth
// @Router /api/v1/settings/telegram [get]
func (h *settingsHandler) getTelegram(c *gin.Context) {
@@ -44,7 +44,7 @@ type updateTelegramRequest struct {
// @Summary 保存配置并返回最新脱敏视图
// @Tags 设置
// @Param body body updateTelegramRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.TelegramView
// @Security BearerAuth
// @Router /api/v1/settings/telegram [put]
func (h *settingsHandler) updateTelegram(c *gin.Context) {
@@ -84,7 +84,7 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
//
// @Summary 返回全部通知渠道的脱敏视图
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels [get]
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
@@ -119,7 +119,7 @@ type updateNotifyChannelRequest struct {
// @Tags 设置
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
// @Param body body updateNotifyChannelRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels/{type} [put]
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
@@ -160,7 +160,7 @@ func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
//
// @Summary 返回全部通知事件开关
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} service.NotifyEventsView
// @Security BearerAuth
// @Router /api/v1/settings/notify-events [get]
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
@@ -178,7 +178,7 @@ func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
// @Summary 全量保存五个事件开关并返回最新值
// @Tags 设置
// @Param body body service.NotifyEventsView true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.NotifyEventsView
// @Security BearerAuth
// @Router /api/v1/settings/notify-events [put]
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
@@ -198,7 +198,7 @@ func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
//
// @Summary 返回全部通知模板
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} itemsResponse[service.NotifyTemplateView]
// @Security BearerAuth
// @Router /api/v1/settings/notify-templates [get]
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
@@ -263,7 +263,7 @@ func (h *settingsHandler) testNotifyTemplate(c *gin.Context) {
//
// @Summary 返回任务行为设置
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} service.TaskSettingsView
// @Security BearerAuth
// @Router /api/v1/settings/task [get]
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
@@ -280,7 +280,7 @@ func (h *settingsHandler) getTaskSettings(c *gin.Context) {
// @Summary 保存任务行为设置并返回最新值
// @Tags 设置
// @Param body body service.TaskSettingsView true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.TaskSettingsView
// @Security BearerAuth
// @Router /api/v1/settings/task [put]
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
@@ -304,7 +304,7 @@ func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
//
// @Summary 返回安全设置
// @Tags 设置
// @Success 200 {object} map[string]any
// @Success 200 {object} service.SecuritySettings
// @Security BearerAuth
// @Router /api/v1/settings/security [get]
func (h *settingsHandler) getSecurity(c *gin.Context) {
@@ -321,7 +321,7 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
// @Summary 保存安全设置并返回最新值
// @Tags 设置
// @Param body body service.SecuritySettings true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} service.SecuritySettings
// @Security BearerAuth
// @Router /api/v1/settings/security [put]
func (h *settingsHandler) updateSecurity(c *gin.Context) {
+5 -3
View File
@@ -4,12 +4,14 @@ import (
"net/http"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/oci" // swagger 注解引用
)
// @Summary 订阅列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.SubscriptionInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subscriptions [get]
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
@@ -29,7 +31,7 @@ func (h *ociConfigHandler) subscriptions(c *gin.Context) {
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Param subscriptionId path string true "subscriptionId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.SubscriptionDetail
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/subscriptions/{subscriptionId} [get]
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
@@ -48,7 +50,7 @@ func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
// @Summary 限额服务列表
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.LimitService
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/limits/services [get]
func (h *ociConfigHandler) limitServices(c *gin.Context) {
+176
View File
@@ -0,0 +1,176 @@
package api
import (
"encoding/json"
"time"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
// 本文件的类型仅供 swagger 文档渲染(@Success/@Failure 注解引用),
// 运行时代码不使用;字段与各 handler 返回的 gin.H 外壳保持一致。
// errorResponse 是统一错误响应外壳。
type errorResponse struct {
Error string `json:"error"`
}
// itemsResponse 是 {"items": [...]} 列表外壳。
type itemsResponse[T any] struct {
Items []T `json:"items"`
}
// pagedResponse 是 {"items": [...], "total": n} 分页外壳。
type pagedResponse[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
}
// itemResponse 是 {"item": {...}} 单项外壳。
type itemResponse[T any] struct {
Item T `json:"item"`
}
// triggerResponse 是任务触发受理响应。
type triggerResponse struct {
Triggered bool `json:"triggered"`
}
// tokenResponse 是登录 / 换发令牌响应。
type tokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
// totpRequiredResponse 是密码通过但需两步验证码的 428 响应。
type totpRequiredResponse struct {
Error string `json:"error"`
TotpRequired bool `json:"totpRequired"`
}
// enabledResponse 是布尔开关查询响应。
type enabledResponse struct {
Enabled bool `json:"enabled"`
}
// totpSetupResponse 是 TOTP 初始化响应。
type totpSetupResponse struct {
Secret string `json:"secret"`
OtpauthUri string `json:"otpauthUri"`
}
// credentialsResponse 是当前登录账号信息。
type credentialsResponse struct {
Username string `json:"username"`
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
}
// oauthProvidersResponse 是外部登录 provider 列表。
type oauthProvidersResponse struct {
Providers []service.ProviderInfo `json:"providers"`
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
}
// urlResponse 是跳转地址响应。
type urlResponse struct {
URL string `json:"url"`
}
// aboutResponse 是「设置 · 关于」页构建与运行信息。
type aboutResponse struct {
Version string `json:"version"`
BuildTime string `json:"buildTime"`
GoVersion string `json:"goVersion"`
Platform string `json:"platform"`
StartedAt string `json:"startedAt"`
UptimeSeconds int64 `json:"uptimeSeconds"`
Resources aboutResources `json:"resources"`
}
// aboutResources 是进程资源占用快照。
type aboutResources struct {
CpuAvgPercent float64 `json:"cpuAvgPercent"`
NumCpu int `json:"numCpu"`
Goroutines int `json:"goroutines"`
MemHeapBytes uint64 `json:"memHeapBytes"`
MemSysBytes uint64 `json:"memSysBytes"`
DbEngine string `json:"dbEngine"`
DbBytes int64 `json:"dbBytes"`
}
// publicIpResponse 是换绑公网 IP 结果。
type publicIpResponse struct {
PublicIp string `json:"publicIp"`
}
// ipv6AddressResponse 是实例新增 IPv6 结果。
type ipv6AddressResponse struct {
Ipv6Address string `json:"ipv6Address"`
}
// addressResponse 是 VNIC 新增 IPv6 结果。
type addressResponse struct {
Address string `json:"address"`
}
// ipv6StepsResponse 是 VCN 开启 IPv6 的分步结果。
type ipv6StepsResponse struct {
Steps []oci.IPv6Step `json:"steps"`
}
// configChangesResponse 是租户配置校验 / 更新结果(config 与字段变更对照)。
type configChangesResponse struct {
Config *model.OciConfig `json:"config"`
Changes service.Changes `json:"changes"`
}
// aiKeyCreateResponse 是 AI 密钥创建结果;key 为明文,仅本次返回。
type aiKeyCreateResponse struct {
Key string `json:"key"`
Item model.AiKey `json:"item"`
}
// usedByResponse 是代理关联租户结果(关联数)。
type usedByResponse struct {
UsedBy int64 `json:"usedBy"`
}
// rawDetailResponse 是审计事件原文响应。
type rawDetailResponse struct {
Raw json.RawMessage `json:"raw"`
}
// passwordResponse 是租户用户重置密码结果(一次性明文)。
type passwordResponse struct {
Password string `json:"password"`
}
// deletedTotpResponse 是清除 MFA 设备结果。
type deletedTotpResponse struct {
DeletedTotpDevices int `json:"deletedTotpDevices"`
}
// deletedApiKeysResponse 是清理用户 API Key 结果。
type deletedApiKeysResponse struct {
DeletedApiKeys int `json:"deletedApiKeys"`
}
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
type createInstancesResponse struct {
Instances []oci.Instance `json:"instances"`
Errors []string `json:"errors"`
}
// logWebhookStatusResponse 是日志回传 webhook 状态。
type logWebhookStatusResponse struct {
Exists bool `json:"exists"`
Webhook *service.LogWebhookInfo `json:"webhook,omitempty"`
}
// webConsoleSessionResponse 是网页控制台会话创建结果。
type webConsoleSessionResponse struct {
SessionId string `json:"sessionId"`
Type string `json:"type"`
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service"
)
@@ -18,7 +19,7 @@ type systemLogHandler struct {
//
// @Summary 系统操作日志列表
// @Tags 设置
// @Success 200 {object} map[string]any "items 与 total"
// @Success 200 {object} pagedResponse[model.SystemLog] "items 与 total"
// @Security BearerAuth
// @Router /api/v1/system-logs [get]
func (h *systemLogHandler) list(c *gin.Context) {
+16 -10
View File
@@ -2,11 +2,13 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service"
)
@@ -32,7 +34,7 @@ type updateTaskRequest struct {
// @Summary 创建任务
// @Tags 任务与日志回传
// @Param body body createTaskRequest true "任务定义(类型/cron/payload)"
// @Success 201 {object} map[string]any
// @Success 201 {object} model.Task
// @Security BearerAuth
// @Router /api/v1/tasks [post]
func (h *taskHandler) create(c *gin.Context) {
@@ -56,7 +58,7 @@ func (h *taskHandler) create(c *gin.Context) {
// @Summary 任务列表
// @Tags 任务与日志回传
// @Success 200 {array} map[string]any
// @Success 200 {array} model.Task
// @Security BearerAuth
// @Router /api/v1/tasks [get]
func (h *taskHandler) list(c *gin.Context) {
@@ -71,7 +73,7 @@ func (h *taskHandler) list(c *gin.Context) {
// @Summary 任务详情
// @Tags 任务与日志回传
// @Param id path int true "任务 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} model.Task
// @Security BearerAuth
// @Router /api/v1/tasks/{id} [get]
func (h *taskHandler) get(c *gin.Context) {
@@ -91,7 +93,7 @@ func (h *taskHandler) get(c *gin.Context) {
// @Tags 任务与日志回传
// @Param id path int true "任务 ID"
// @Param body body updateTaskRequest true "可更新字段"
// @Success 200 {object} map[string]any
// @Success 200 {object} model.Task
// @Security BearerAuth
// @Router /api/v1/tasks/{id} [put]
func (h *taskHandler) update(c *gin.Context) {
@@ -138,7 +140,7 @@ func (h *taskHandler) remove(c *gin.Context) {
// @Summary 任务执行日志
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} model.TaskLog
// @Security BearerAuth
// @Router /api/v1/tasks/{id}/logs [get]
func (h *taskHandler) logs(c *gin.Context) {
@@ -155,10 +157,11 @@ func (h *taskHandler) logs(c *gin.Context) {
c.JSON(http.StatusOK, logs)
}
// @Summary 立即执行任务
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 202 {object} triggerResponse
// @Failure 409 {object} errorResponse "任务正在执行中"
// @Security BearerAuth
// @Router /api/v1/tasks/{id}/run [post]
func (h *taskHandler) run(c *gin.Context) {
@@ -166,10 +169,13 @@ func (h *taskHandler) run(c *gin.Context) {
if !ok {
return
}
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
if err != nil {
if err := h.svc.TriggerTask(c.Request.Context(), id); err != nil {
if errors.Is(err, service.ErrTaskRunning) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, entry)
c.JSON(http.StatusAccepted, gin.H{"triggered": true})
}
+27 -20
View File
@@ -18,7 +18,7 @@ import (
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.InstanceTraffic
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/traffic [get]
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
@@ -38,7 +38,12 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
// @Summary 配置成本快照
// @Tags 成本
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Param startTime query string false "起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤)"
// @Param endTime query string false "结束时间 RFC3339,缺省为当前时刻"
// @Param granularity query string false "HOURLY / DAILY / MONTHLY,缺省 DAILY"
// @Param queryType query string false "COST / USAGE,缺省 COST"
// @Param groupBy query string false "分组维度,单维或复合(如 service,skuName),缺省 service"
// @Success 200 {array} oci.CostItem
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/costs [get]
func (h *ociConfigHandler) costs(c *gin.Context) {
@@ -68,14 +73,16 @@ 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"
// @Success 200 {object} map[string]any
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
// @Success 200 {object} service.AuditEventsView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/audit-events [get]
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
@@ -84,7 +91,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()})
@@ -103,7 +110,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} rawDetailResponse "raw 为事件原文 JSON"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
@@ -145,7 +152,7 @@ type createTenantUserRequest struct {
// @Summary 租户身份域列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.IdentityDomain
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/domains [get]
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
@@ -165,7 +172,7 @@ func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.TenantUser
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users [get]
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
@@ -186,7 +193,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.TenantUserDetail
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId} [get]
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
@@ -207,7 +214,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createTenantUserRequest true "请求体"
// @Success 201 {object} map[string]any
// @Success 201 {object} oci.TenantUser
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users [post]
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
@@ -253,7 +260,7 @@ type updateTenantUserRequest struct {
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Param body body updateTenantUserRequest true "请求体"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.TenantUser
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId} [put]
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
@@ -306,7 +313,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Success 200 {object} passwordResponse "一次性明文密码"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/reset-password [post]
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
@@ -327,7 +334,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Success 200 {object} deletedTotpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/mfa-devices [delete]
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
@@ -347,7 +354,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Success 200 {object} deletedApiKeysResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
@@ -370,7 +377,7 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.NotificationRecipients
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
@@ -391,7 +398,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.NotificationRecipients
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/notification-recipients [put]
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
@@ -418,7 +425,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {array} oci.PasswordPolicyInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/password-policies [get]
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
@@ -440,7 +447,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param policyId path string true "policyId"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.PasswordPolicyInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/password-policies/{policyId} [put]
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
@@ -473,7 +480,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.IdentitySettingInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
@@ -494,7 +501,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Success 200 {object} oci.IdentitySettingInfo
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-settings [put]
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
+1 -1
View File
@@ -29,7 +29,7 @@ type createConsoleSessionRequest struct {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "实例 OCID"
// @Param body body object true "{type: serial|vnc}"
// @Success 200 {object} map[string]any "sessionId 与 wsPath"
// @Success 201 {object} webConsoleSessionResponse "sessionId 与 type"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post]
func (h *consoleHandler) create(c *gin.Context) {
+1 -1
View File
@@ -53,7 +53,7 @@ type webhookHandler struct {
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
// @Success 200 "已受理"
// @Failure 403 {object} map[string]string "时间戳超窗或证书源非 Oracle 域"
// @Failure 403 {object} errorResponse "时间戳超窗或证书源非 Oracle 域"
// @Failure 404 "secret 无效(不暴露端点存在性)"
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
func (h *webhookHandler) handle(c *gin.Context) {
+2 -2
View File
@@ -67,8 +67,8 @@ func autoMigrate(db *gorm.DB) error {
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.CheckSnapshot{}, &model.CostSnapshot{},
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
&model.SystemLog{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.Proxy{},
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
&model.AiContentLog{},
)
}
+16 -26
View File
@@ -211,31 +211,6 @@ type LogEvent struct {
ReceivedAt time.Time `json:"receivedAt"`
}
// AlertRule 是回传事件的自定义告警规则;条件间 AND 关系,空条件视为任意。
type AlertRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64" json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"` // 0=全部租户
EventTypes string `gorm:"size:512" json:"eventTypes"` // 逗号分隔事件短名,空=全部
SourceIPs string `gorm:"size:512" json:"sourceIps"` // 逗号分隔 IP/CIDR,空=任意
// in:来源命中列表才告警(默认);notin:不在列表才告警(白名单场景)
SourceIPMode string `gorm:"size:8" json:"sourceIpMode"`
ResourceMatch string `gorm:"size:128" json:"resourceMatch"` // 资源名子串,空=任意
Threshold int `json:"threshold"` // 触发阈值,默认 1(即时)
WindowMinutes int `json:"windowMinutes"` // 聚合窗口,Threshold>1 时必填
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// AlertRuleHit 记录规则命中,供阈值窗口计数;随周期清理删除过期行。
type AlertRuleHit struct {
ID uint `gorm:"primaryKey" json:"id"`
RuleID uint `gorm:"index" json:"ruleId"`
LogEventID uint `json:"logEventId"`
HitAt time.Time `gorm:"index" json:"hitAt"`
}
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
type RegionCache struct {
@@ -254,6 +229,7 @@ type CompartmentCache struct {
OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"`
OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"`
Name string `gorm:"size:128" json:"name"`
ParentOCID string `gorm:"size:128" json:"parentId"`
State string `gorm:"size:16" json:"lifecycleState"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -266,6 +242,8 @@ type AiKey struct {
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
Tail string `gorm:"size:4" json:"tail"`
Group string `gorm:"column:key_group;size:32" json:"group"`
// Models 是模型白名单(精确匹配模型名);空 = 不限,非空时网关仅放行列表内模型
Models []string `gorm:"serializer:json;type:text" json:"models"`
Enabled bool `json:"enabled"`
LastUsedAt *time.Time `json:"lastUsedAt"`
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
@@ -291,12 +269,16 @@ type AiChannel struct {
LastProbeAt *time.Time `json:"lastProbeAt"`
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
ProbeError string `gorm:"size:512" json:"probeError"`
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
ProbeModel string `gorm:"size:96" json:"probeModel"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// ModelCount 是渠道模型缓存计数,列表查询回填,不落库
ModelCount int64 `gorm:"-" json:"modelCount"`
}
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
// Capability 为 CHAT / EMBEDDING;存量空串视为 CHAT(加列前只同步对话模型)。
// Capability 为 CHAT / EMBEDDING / RERANK / TTS;存量空串视为 CHAT(加列前只同步对话模型)。
type AiModelCache struct {
ID uint `gorm:"primaryKey" json:"-"`
ChannelID uint `gorm:"index" json:"channelId"`
@@ -311,6 +293,14 @@ type AiModelCache struct {
RetiredAt *time.Time `json:"retiredAt"`
}
// AiModelBlacklist 是全局模型黑名单(按模型名):加入即从全部渠道缓存删除该模型,
// 同步与探测过滤不再入库;适用于微调基座等实际不可按需调用的模型,由用户手动维护。
type AiModelBlacklist struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:96;uniqueIndex" json:"name"`
CreatedAt time.Time `json:"createdAt"`
}
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
type AiCallLog struct {
ID uint `gorm:"primaryKey" json:"id"`
+471 -186
View File
@@ -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
View File
@@ -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 比较两个 DTOEventTime 按值比较,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)
}
})
}
}
+320
View File
@@ -0,0 +1,320 @@
package oci
import (
"context"
"fmt"
"io"
"math"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/ospgateway"
)
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
type Invoice struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
Type string `json:"type"`
Status string `json:"status"`
IsPaid bool `json:"isPaid"`
IsPayable bool `json:"isPayable"`
IsPaymentFailed bool `json:"isPaymentFailed"`
Amount float64 `json:"amount"`
AmountDue float64 `json:"amountDue"`
Currency string `json:"currency"`
TimeInvoice *time.Time `json:"timeInvoice"`
TimeDue *time.Time `json:"timeDue"`
}
// InvoiceLine 是发票内一行费用明细。
type InvoiceLine struct {
Product string `json:"product"`
OrderNo string `json:"orderNo"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
Total float64 `json:"total"`
Currency string `json:"currency"`
TimeStart *time.Time `json:"timeStart"`
TimeEnd *time.Time `json:"timeEnd"`
}
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
type PaymentMethod struct {
Method string `json:"method"` // CREDIT_CARD / PAYPAL
CardType string `json:"cardType,omitempty"`
LastDigits string `json:"lastDigits,omitempty"`
NameOnCard string `json:"nameOnCard,omitempty"`
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
Email string `json:"email,omitempty"`
PayerName string `json:"payerName,omitempty"`
BillingAgreement string `json:"billingAgreement,omitempty"`
}
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
info, err := c.ValidateKey(ctx, cred)
if err != nil {
return "", fmt.Errorf("resolve osp home region: %w", err)
}
r, ok := RegionByKey(info.HomeRegionKey)
if !ok {
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
}
if cred.TenancyOCID != "" {
c.homeRegions.Store(cred.TenancyOCID, r.Name)
}
return r.Name, nil
}
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", err
}
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
}
applyProxy(&ic.BaseClient, cred)
ic.SetRegion(region)
return ic, region, nil
}
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
// (按 timeInvoice 窗口过滤),为 0 拉全量。
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
req := ospgateway.ListInvoicesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Limit: common.Int(ospPageLimit),
}
if year > 0 {
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
}
var out []Invoice
for {
resp, err := ic.ListInvoices(ctx, req)
if err != nil {
return nil, fmt.Errorf("list invoices: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoice(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
req.Page = resp.OpcNextPage
}
}
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
const ospPageLimit = 100
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
var out []InvoiceLine
var page *string
for {
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
Page: page,
Limit: common.Int(ospPageLimit),
})
if err != nil {
return nil, fmt.Errorf("list invoice lines: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoiceLine(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
})
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
}
return data, nil
}
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return err
}
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
})
if err != nil {
return fmt.Errorf("pay invoice: %w", err)
}
return nil
}
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return nil, err
}
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return nil, fmt.Errorf("new osp subscription client: %w", err)
}
applyProxy(&sc.BaseClient, cred)
sc.SetRegion(region)
var out []PaymentMethod
var page *string
for {
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
for _, sub := range resp.Items {
out = appendPaymentMethods(out, sub.PaymentOptions)
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
inv := Invoice{
ID: deref(v.InvoiceId),
InternalID: deref(v.InternalInvoiceId),
Number: deref(v.InvoiceNumber),
Type: string(v.InvoiceType),
Status: string(v.InvoiceStatus),
Amount: money(v.InvoiceAmount),
AmountDue: money(v.InvoiceAmountDue),
TimeInvoice: sdkTime(v.TimeInvoice),
TimeDue: sdkTime(v.TimeInvoiceDue),
}
if v.IsPaid != nil {
inv.IsPaid = *v.IsPaid
}
if v.IsPayable != nil {
inv.IsPayable = *v.IsPayable
}
if v.IsPaymentFailed != nil {
inv.IsPaymentFailed = *v.IsPaymentFailed
}
if v.Currency != nil {
inv.Currency = deref(v.Currency.CurrencyCode)
}
return inv
}
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
line := InvoiceLine{
Product: deref(v.Product),
OrderNo: deref(v.OrderNo),
Quantity: money(v.Quantity),
UnitPrice: money(v.NetUnitPrice),
Total: money(v.TotalPrice),
TimeStart: sdkTime(v.TimeStart),
TimeEnd: sdkTime(v.TimeEnd),
}
if v.Currency != nil {
line.Currency = deref(v.Currency.CurrencyCode)
}
return line
}
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
for _, opt := range opts {
switch v := opt.(type) {
case ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(v))
case *ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(*v))
case ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(v))
case *ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(*v))
}
}
return out
}
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "CREDIT_CARD",
CardType: string(v.CreditCardType),
LastDigits: deref(v.LastDigits),
NameOnCard: deref(v.NameOnCard),
TimeExpiration: sdkTime(v.TimeExpiration),
}
}
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "PAYPAL",
Email: deref(v.EmailAddress),
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
BillingAgreement: deref(v.ExtBillingAgreementId),
}
}
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
func money(p *float32) float64 {
if p == nil {
return 0
}
return math.Round(float64(*p)*10000) / 10000
}
+3 -1
View File
@@ -32,8 +32,10 @@ func NewCachedClient(inner Client) *CachedClient {
}
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
// compartment 必须参与键值:列表查询按 EffectiveCompartment 过滤,
// 同租户切换区间时若共用键会串到上一个区间的缓存结果。
func ckey(cred Credentials, parts ...string) string {
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
return cred.TenancyOCID + "|" + cred.CompartmentID + "|" + strings.Join(parts, "|")
}
// bust 写操作成功后失效该租户全部读缓存。
+7
View File
@@ -49,6 +49,13 @@ func TestCachedClientHitAndIsolation(t *testing.T) {
if inner.instCalls != 3 {
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
}
// 同租户不同 compartment 各自回源,不得共用缓存
inCompartment := testCred("t1")
inCompartment.CompartmentID = "ocid1.compartment.a"
_, _ = c.ListInstances(ctx, inCompartment, "r1")
if inner.instCalls != 4 {
t.Errorf("跨 compartment 回源 %d 次, want 4", inner.instCalls)
}
}
func TestCachedClientWriteBusts(t *testing.T) {
+50 -2
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
@@ -49,6 +50,13 @@ type Client interface {
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
// GetSubscription 查询单个订阅的完整信息。
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
// 账单(OSP Gateway,仅主区域):发票列表/明细/PDF/付款与订阅付款方式。
// ListInvoices 的 year>0 时按自然年过滤(timeInvoice 窗口),0 为全量。
ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error)
ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error)
DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error)
PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error
ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error)
// ListShapes 列出租户在目标区域可用的实例规格(带缓存)。
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
// ListImages 列出可用的实例镜像。
@@ -90,6 +98,34 @@ type Client interface {
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error)
ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error)
// 对象存储:namespace、桶 CRUD、对象列表/删除/重命名/取回/元数据、预签名请求(PAR)。
GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error)
ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error)
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error
RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error
RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error
HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error)
// 小文件中转:预览/编辑经面板直读直写,不签发 PAR;PutObject ifMatch 做并发保护。
GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error)
PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error)
CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error)
ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error)
// ListPARsPage 单页列出 PAR:page 为上页游标(空取首页),返回下一页游标(空为末页)。
ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error)
DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error
// 保留公网 IP:列出 / 创建 / 绑定(instanceID 空为解绑) / 删除。
ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error)
CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error)
AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error
AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error
DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error
AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error)
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
@@ -99,10 +135,20 @@ type Client interface {
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error)
GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error)
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
// GenAiCompatResponses 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路);
// wait 是上游无响应预算(整请求总超时),multi-agent/搜索类模型需远超 SDK 默认 60s。
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error)
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body;
// wait 仅约束等待响应头阶段,建立后的流生命周期由 ctx 决定。
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error)
// GenAiCompatSpeech 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error)
// GenAiApplyGuardrails 对单条文本执行内容审核 / PII / 提示注入检测。
GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error)
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
@@ -175,6 +221,8 @@ type Client interface {
type RealClient struct {
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
shapes sync.Map // tenancy|region|ad → shapeCacheEntryshape 清单缓存
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
}
// NewClient 返回生产使用的真实客户端。
+38 -13
View File
@@ -14,15 +14,16 @@ import (
type CostQuery struct {
StartTime time.Time
EndTime time.Time
Granularity string // DAILY / MONTHLY
Granularity string // HOURLY / DAILY / MONTHLY
QueryType string // COST / USAGE
GroupBy string // service / skuName / region 等维度
GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子)
}
// CostItem 是一个时间桶内某分组维度的成本或用量。
type CostItem struct {
TimeStart *time.Time `json:"timeStart"`
GroupValue string `json:"groupValue"`
SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName)
ComputedAmount float32 `json:"computedAmount"`
ComputedQuantity float32 `json:"computedQuantity"`
Currency string `json:"currency"`
@@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
Granularity: granularity,
QueryType: queryType,
GroupBy: []string{q.GroupBy},
GroupBy: splitGroupBy(q.GroupBy),
}, nil
}
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段
// splitGroupBy 把 "service,skuName" 复合维度拆成 Usage API 的 groupBy 列表
func splitGroupBy(groupBy string) []string {
parts := strings.Split(groupBy, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// dimValue 按维度名取响应条目上的分组值。
func dimValue(item usageapi.UsageSummary, dim string) string {
switch strings.ToLower(strings.TrimSpace(dim)) {
case "skuname":
return deref(item.SkuName)
case "region":
return deref(item.Region)
case "compartmentname":
return deref(item.CompartmentName)
default:
return deref(item.Service)
}
}
// toCostItem 把响应条目转为本地 DTO;首维进 GroupValue,复合分组的次维进 SubValue。
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
out := CostItem{
Currency: deref(item.Currency),
@@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
if item.ComputedQuantity != nil {
out.ComputedQuantity = *item.ComputedQuantity
}
switch strings.ToLower(groupBy) {
case "skuname":
out.GroupValue = deref(item.SkuName)
case "region":
out.GroupValue = deref(item.Region)
case "compartmentname":
out.GroupValue = deref(item.CompartmentName)
default:
out.GroupValue = deref(item.Service)
dims := splitGroupBy(groupBy)
if len(dims) == 0 {
dims = []string{"service"}
}
out.GroupValue = dimValue(item, dims[0])
if len(dims) > 1 {
out.SubValue = dimValue(item, dims[1])
}
return out
}
+31
View File
@@ -76,3 +76,34 @@ func ServiceStatus(err error) (int, bool) {
}
return 0, false
}
// IsOnDemandUnsupported 识别「模型在该区域仅为微调基座、不支持按需调用」的 400:
// OCI 消息形如 "Not allowed to call finetune base model …, use Endpoint: false"。
// ListModels 无字段可事先区分,只能在调用报错时识别并换渠道。
func IsOnDemandUnsupported(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
return svcErr.GetHTTPStatusCode() == 400 &&
strings.Contains(strings.ToLower(svcErr.GetMessage()), "finetune base model")
}
// IsEntityNotFound 识别「实体不存在」404(消息 "Entity with key … not found"):
// GenAI 对区域内无按需供给的模型 OCID 返回此类 404,属模型级错误;
// 鉴权失败的 404 是 NotAuthorizedOrNotFound(消息为 Authorization failed…),不在此列。
func IsEntityNotFound(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
msg := strings.ToLower(svcErr.GetMessage())
return svcErr.GetHTTPStatusCode() == 404 &&
strings.Contains(msg, "entity with key") && strings.Contains(msg, "not found")
}
// IsModelUnavailable 归并「模型×区域不可按需调用」两类报错:微调基座 400 与实体不存在 404;
// 命中即应把该 (渠道, 模型) 从池中剔除并换候选/换渠道,而非定论租户配额问题。
func IsModelUnavailable(err error) bool {
return IsOnDemandUnsupported(err) || IsEntityNotFound(err)
}
+49
View File
@@ -109,3 +109,52 @@ func TestErrorHint(t *testing.T) {
})
}
}
func TestIsOnDemandUnsupported(t *testing.T) {
ft := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"微调基座 400 命中(含包装链)", fmt.Errorf("genai chat: %w", ft), true},
{"同消息但非 400 不命中", fakeServiceError{status: 500, code: "InternalError", message: ft.message}, false},
{"普通 400 不命中", fakeServiceError{status: 400, code: "InvalidParameter", message: "bad request"}, false},
{"非服务端错误不命中", errors.New("finetune base model"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOnDemandUnsupported(tt.err); got != tt.want {
t.Errorf("IsOnDemandUnsupported() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsModelUnavailable(t *testing.T) {
entity404 := fakeServiceError{status: 404, code: "NotFound",
message: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
auth404 := fakeServiceError{status: 404, code: "NotAuthorizedOrNotFound",
message: "Authorization failed or requested resource not found."}
ft400 := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1..x, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"实体不存在 404 命中(含包装链)", fmt.Errorf("genai chat: %w", entity404), true},
{"鉴权类 404 不命中(仍属租户级)", auth404, false},
{"微调基座 400 命中", ft400, true},
{"其他 404 消息不命中", fakeServiceError{status: 404, code: "NotFound", message: "route not found"}, false},
{"非服务端错误不命中", errors.New("entity with key x not found"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsModelUnavailable(tt.err); got != tt.want {
t.Errorf("IsModelUnavailable() = %v, want %v", got, tt.want)
}
})
}
}
+63 -129
View File
@@ -2,13 +2,11 @@ package oci
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/generativeai"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
@@ -20,7 +18,6 @@ type GenAiModel struct {
Ocid string `json:"ocid"`
Name string `json:"name"`
Vendor string `json:"vendor"`
ChatOnly bool `json:"-"`
Caps []string `json:"capabilities"`
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
Capability string `json:"capability"`
@@ -31,12 +28,6 @@ type GenAiModel struct {
Retired *time.Time `json:"retired"`
}
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
type GenAiStream interface {
Next() (aiwire.ChatChunk, error)
Close() error
}
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
if err != nil {
@@ -71,18 +62,39 @@ func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, regi
if err != nil {
return nil, fmt.Errorf("list genai models: %w", err)
}
seen := map[string]bool{}
now := time.Now()
return dedupGenAiModels(resp.Items, time.Now()), nil
}
// dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目
// (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。
func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel {
seen := map[string]int{}
var out []GenAiModel
for _, m := range resp.Items {
for _, m := range items {
gm, ok := toGenAiModel(m, now)
if !ok || seen[gm.Name] {
if !ok {
continue
}
seen[gm.Name] = true
if i, dup := seen[gm.Name]; dup {
if hasFineTune(out[i].Caps) && !hasFineTune(gm.Caps) {
out[i] = gm
}
continue
}
seen[gm.Name] = len(out)
out = append(out, gm)
}
return out, nil
return out
}
// hasFineTune 判断能力列表是否含 FINE_TUNE(微调基座条目)。
func hasFineTune(caps []string) bool {
for _, c := range caps {
if c == string(generativeai.ModelCapabilityFineTune) {
return true
}
}
return false
}
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
@@ -119,6 +131,11 @@ func modelCapability(m generativeai.ModelSummary) string {
return "CHAT"
case generativeai.ModelCapabilityTextEmbeddings:
capability = "EMBEDDING"
case generativeai.ModelCapabilityTextRerank:
capability = "RERANK"
case generativeai.ModelCapabilityEnum("TEXT_TO_AUDIO"):
// SDK v65.120 尚无该枚举常量,按原始字符串匹配(xai.grok-tts)
capability = "TTS"
}
}
return capability
@@ -132,121 +149,17 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
return out
}
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
req, err := buildChatRequest(ir, false)
if err != nil {
return nil, err
}
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
if err != nil {
return nil, fmt.Errorf("genai chat: %w", err)
}
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
}
// buildChatRequest 按模型 vendor 组装底层请求体。
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
if isCohereModel(ir.Model) {
return irToCohereSDK(ir, stream)
}
return irToSDK(ir, stream), nil
}
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
switch r := resp.(type) {
case generativeaiinference.GenericChatResponse:
return sdkToIR(r, model), nil
case generativeaiinference.CohereChatResponse:
return cohereSDKToIR(r, model), nil
default:
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
}
}
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
return generativeaiinference.ChatRequest{
ChatDetails: generativeaiinference.ChatDetails{
CompartmentId: &cred.TenancyOCID,
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
ChatRequest: req,
},
}
}
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
req, err := buildChatRequest(ir, true)
if err != nil {
return nil, err
}
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
if err != nil {
return nil, fmt.Errorf("genai chat stream: %w", err)
}
reader, err := common.NewSSEReader(resp.RawResponse)
if err != nil {
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
}
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
}
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
type genAiSSEStream struct {
reader *common.SseReader
body io.ReadCloser
model string
}
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
for {
data, err := s.reader.ReadNextEvent()
if err != nil {
return aiwire.ChatChunk{}, err
}
text := strings.TrimSpace(string(data))
if text == "" {
continue
}
if text == "[DONE]" {
return aiwire.ChatChunk{}, io.EOF
}
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
return chunk, nil
}
}
}
func (s *genAiSSEStream) Close() error {
if s.body != nil {
return s.body.Close()
}
return nil
}
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
// modelName 决定请求格式(cohere.* 走 COHERE)。
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;
// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16:
// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
one := 1
ir := aiwire.ChatRequest{
Model: modelName,
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
MaxTokens: &one,
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
"max_output_tokens": 16, "store": false})
if err != nil {
return 0, err
}
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
if err == nil {
// 探测追求快速失败,沿用 SDK 默认量级的 60s 预算即可
if _, err = c.GenAiCompatResponses(ctx, cred, region, body, 60*time.Second); err == nil {
return http.StatusOK, nil
}
if status, ok := ServiceStatus(err); ok {
@@ -255,6 +168,27 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
return 0, err
}
// sdkUsageToIR 把 SDK 用量转为内部记账结构(缓存命中挂 details,仅命中时出现)。
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
if u == nil {
return nil
}
out := &aiwire.Usage{}
if u.PromptTokens != nil {
out.PromptTokens = *u.PromptTokens
}
if u.CompletionTokens != nil {
out.CompletionTokens = *u.CompletionTokens
}
if u.TotalTokens != nil {
out.TotalTokens = *u.TotalTokens
}
if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens != nil && *u.PromptTokensDetails.CachedTokens > 0 {
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *u.PromptTokensDetails.CachedTokens}
}
return out
}
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
ic, err := c.genAiInferenceClient(cred, region)
-266
View File
@@ -1,266 +0,0 @@
package oci
import (
"encoding/json"
"fmt"
"strings"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。
func isCohereModel(name string) bool {
return strings.HasPrefix(strings.ToLower(name), "cohere.")
}
// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级
// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。
func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) {
var req generativeaiinference.CohereChatRequest
if err := cohereRejectUnsupported(ir); err != nil {
return req, err
}
p, err := cohereSplitMessages(ir.Messages)
if err != nil {
return req, err
}
req = generativeaiinference.CohereChatRequest{
Message: &p.message,
ChatHistory: p.history,
ToolResults: p.toolResults,
Tools: cohereTools(ir.Tools),
ResponseFormat: cohereResponseFormat(ir.ResponseFormat),
MaxTokens: ir.MaxTokens,
Temperature: ir.Temperature,
TopP: ir.TopP,
TopK: ir.TopK,
FrequencyPenalty: ir.FrequencyPenalty,
PresencePenalty: ir.PresencePenalty,
Seed: ir.Seed,
}
if p.preamble != "" {
req.PreambleOverride = &p.preamble
}
if len(ir.Stop) > 0 {
req.StopSequences = ir.Stop
}
if stream {
req.IsStream = &stream
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
}
return req, nil
}
// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。
func cohereRejectUnsupported(ir aiwire.ChatRequest) error {
for _, m := range ir.Messages {
for _, p := range m.Content.Parts {
if p.Type == "image_url" {
return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型")
}
}
}
return nil
}
// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。
type cohereParts struct {
message string
history []generativeaiinference.CohereMessage
preamble string
toolResults []generativeaiinference.CohereToolResult
}
// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message,
// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults
// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。
func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) {
lastUser, lastTool := -1, -1
for i, m := range msgs {
switch m.Role {
case "user":
lastUser = i
case "tool":
lastTool = i
}
}
if lastUser == -1 && lastTool == -1 {
return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息")
}
var p cohereParts
var preamble []string
calls := map[string]generativeaiinference.CohereToolCall{}
for i, m := range msgs {
text := m.Content.JoinText()
switch m.Role {
case "system", "developer":
preamble = append(preamble, text)
case "assistant":
p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls))
case "tool":
p.toolResults = append(p.toolResults, cohereToolResult(m, calls))
default: // user
if i == lastUser && lastUser > lastTool {
p.message = text
continue
}
p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text})
}
}
p.preamble = strings.Join(preamble, "\n")
return p, nil
}
// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。
func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage {
msg := generativeaiinference.CohereChatBotMessage{}
if text != "" {
msg.Message = &text
}
for _, tc := range tcs {
name := tc.Function.Name
var params interface{}
if json.Unmarshal([]byte(tc.Function.Arguments), &params) != nil || params == nil {
params = map[string]any{}
}
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
calls[tc.ID] = call
msg.ToolCalls = append(msg.ToolCalls, call)
}
return msg
}
// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id,
// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。
func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult {
call, ok := calls[m.ToolCallID]
if !ok {
name := m.ToolCallID
var params interface{} = map[string]any{}
call = generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
}
text := m.Content.JoinText()
var output interface{}
if json.Unmarshal([]byte(text), &output) != nil || output == nil {
output = map[string]any{"output": text}
}
if arr, isArr := output.([]interface{}); isArr {
return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr}
}
if _, isMap := output.(map[string]interface{}); !isMap {
output = map[string]any{"output": output}
}
return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}}
}
// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。
func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool {
if len(tools) == 0 {
return nil
}
out := make([]generativeaiinference.CohereTool, 0, len(tools))
for _, t := range tools {
name, desc := t.Function.Name, t.Function.Description
if desc == "" {
desc = name // Description 为 COHERE 必填
}
out = append(out, generativeaiinference.CohereTool{
Name: &name, Description: &desc,
ParameterDefinitions: cohereParams(t.Function.Parameters),
})
}
return out
}
// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。
func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition {
var s struct {
Properties map[string]struct {
Type string `json:"type"`
Description string `json:"description"`
} `json:"properties"`
Required []string `json:"required"`
}
if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 {
return nil
}
req := map[string]bool{}
for _, r := range s.Required {
req[r] = true
}
out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties))
for k, v := range s.Properties {
typ, desc := v.Type, v.Description
if typ == "" {
typ = "string"
}
pd := generativeaiinference.CohereParameterDefinition{Type: &typ}
if desc != "" {
pd.Description = &desc
}
if req[k] {
t := true
pd.IsRequired = &t
}
out[k] = pd
}
return out
}
// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。
func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat {
if rf == nil {
return nil
}
switch rf.Type {
case "json_object", "json_schema":
out := generativeaiinference.CohereResponseJsonFormat{}
var in struct {
Schema json.RawMessage `json:"schema"`
}
if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 {
var schema interface{}
if json.Unmarshal(in.Schema, &schema) == nil {
out.Schema = &schema
}
}
return out
}
return nil
}
// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。
func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse {
msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))}
for i, tc := range resp.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i))
}
finish := mapFinishReason(string(resp.FinishReason))
if len(msg.ToolCalls) > 0 {
finish = "tool_calls"
}
return &aiwire.ChatResponse{
Object: "chat.completion",
Model: model,
Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}},
Usage: sdkUsageToIR(resp.Usage),
}
}
// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。
func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall {
args := "{}"
if params != nil && *params != nil {
if b, err := json.Marshal(*params); err == nil {
args = string(b)
}
}
return aiwire.ToolCall{
ID: fmt.Sprintf("call_%s_%d", name, idx),
Type: "function",
Function: aiwire.FunctionCall{Name: name, Arguments: args},
}
}
-367
View File
@@ -1,367 +0,0 @@
package oci
import (
"encoding/json"
"strings"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。
func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest {
req := generativeaiinference.GenericChatRequest{
Messages: irMessages(ir.Messages),
MaxTokens: ir.MaxTokens,
MaxCompletionTokens: ir.MaxCompletionTokens,
Temperature: ir.Temperature,
TopP: ir.TopP,
TopK: ir.TopK,
FrequencyPenalty: ir.FrequencyPenalty,
PresencePenalty: ir.PresencePenalty,
Seed: ir.Seed,
NumGenerations: ir.N,
Tools: irTools(ir.Tools),
ToolChoice: irToolChoice(ir.ToolChoice),
ResponseFormat: irResponseFormat(ir.ResponseFormat),
}
if len(ir.Stop) > 0 {
req.Stop = ir.Stop
}
if ir.ReasoningEffort != "" {
req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort))
}
if stream {
req.IsStream = &stream
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
}
return req
}
// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。
func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message {
out := make([]generativeaiinference.Message, 0, len(msgs))
for _, m := range msgs {
switch m.Role {
case "system", "developer":
out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())})
case "assistant":
out = append(out, generativeaiinference.AssistantMessage{
Content: textContents(m.Content.JoinText()),
ToolCalls: irToolCalls(m.ToolCalls),
})
case "tool":
tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())}
if m.ToolCallID != "" {
id := m.ToolCallID
tm.ToolCallId = &id
}
out = append(out, tm)
default: // user
out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)})
}
}
return out
}
// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。
func chatContents(c aiwire.Content) []generativeaiinference.ChatContent {
if len(c.Parts) == 0 {
return textContents(c.Text)
}
var out []generativeaiinference.ChatContent
for _, p := range c.Parts {
switch {
case p.Type == "image_url" && p.ImageURL != nil:
img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL}
if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok {
img.Detail = d
}
out = append(out, generativeaiinference.ImageContent{ImageUrl: &img})
case p.Text != "":
t := p.Text
out = append(out, generativeaiinference.TextContent{Text: &t})
}
}
return out
}
// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。
func textContents(text string) []generativeaiinference.ChatContent {
if text == "" {
return nil
}
return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}}
}
func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall {
if len(calls) == 0 {
return nil
}
out := make([]generativeaiinference.ToolCall, 0, len(calls))
for _, c := range calls {
id, name, args := c.ID, c.Function.Name, c.Function.Arguments
out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args})
}
return out
}
func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition {
if len(tools) == 0 {
return nil
}
out := make([]generativeaiinference.ToolDefinition, 0, len(tools))
for _, t := range tools {
name, desc := t.Function.Name, t.Function.Description
fd := generativeaiinference.FunctionDefinition{Name: &name}
if desc != "" {
fd.Description = &desc
}
if len(t.Function.Parameters) > 0 {
var params interface{}
if json.Unmarshal(t.Function.Parameters, &params) == nil {
fd.Parameters = &params
}
}
out = append(out, fd)
}
return out
}
// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。
func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
switch s {
case "none":
return generativeaiinference.ToolChoiceNone{}
case "required":
return generativeaiinference.ToolChoiceRequired{}
case "auto":
return generativeaiinference.ToolChoiceAuto{}
}
return nil
}
var obj struct {
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" {
return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name}
}
return nil
}
func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat {
if rf == nil {
return nil
}
switch rf.Type {
case "json_object":
return generativeaiinference.JsonObjectResponseFormat{}
case "json_schema":
return irJSONSchemaFormat(rf.JSONSchema)
default:
return nil
}
}
// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。
func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat {
var in struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Strict *bool `json:"strict"`
}
if json.Unmarshal(raw, &in) != nil || in.Name == "" {
return generativeaiinference.JsonObjectResponseFormat{}
}
js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict}
if in.Description != "" {
js.Description = &in.Description
}
if len(in.Schema) > 0 {
var schema interface{}
if json.Unmarshal(in.Schema, &schema) == nil {
js.Schema = &schema
}
}
return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js}
}
// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。
func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse {
out := &aiwire.ChatResponse{Object: "chat.completion", Model: model}
if resp.TimeCreated != nil {
out.Created = resp.TimeCreated.Unix()
}
for i, ch := range resp.Choices {
idx := i
if ch.Index != nil {
idx = *ch.Index
}
out.Choices = append(out.Choices, aiwire.Choice{
Index: idx,
Message: sdkMessageToIR(ch.Message),
FinishReason: mapFinishReason(deref(ch.FinishReason)),
})
}
out.Usage = sdkUsageToIR(resp.Usage)
return out
}
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
if u == nil {
return nil
}
out := &aiwire.Usage{}
if u.PromptTokens != nil {
out.PromptTokens = *u.PromptTokens
}
if u.CompletionTokens != nil {
out.CompletionTokens = *u.CompletionTokens
}
if u.TotalTokens != nil {
out.TotalTokens = *u.TotalTokens
}
if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 {
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens}
}
return out
}
// sdkMessageToIR 提取助手消息文本与工具调用。
func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage {
out := aiwire.ChatMessage{Role: "assistant"}
am, ok := m.(generativeaiinference.AssistantMessage)
if !ok {
return out
}
var sb strings.Builder
for _, c := range am.Content {
if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil {
sb.WriteString(*tc.Text)
}
}
out.Content = aiwire.NewTextContent(sb.String())
for _, call := range am.ToolCalls {
if fc, ok := call.(generativeaiinference.FunctionCall); ok {
out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{
ID: deref(fc.Id),
Type: "function",
Function: aiwire.FunctionCall{
Name: deref(fc.Name),
Arguments: deref(fc.Arguments),
},
})
}
}
return out
}
// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。
func mapFinishReason(reason string) string {
switch strings.ToLower(reason) {
case "", "null":
return ""
case "stop", "completed", "end_turn":
return "stop"
case "length", "max_tokens":
return "length"
case "tool_calls", "tool_call", "tool_use":
return "tool_calls"
case "complete":
return "stop"
case "error_toxic":
return "content_filter"
default:
return strings.ToLower(reason)
}
}
// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。
type genAiStreamEvent struct {
Index *int `json:"index"`
Message struct {
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"toolCalls"`
} `json:"message"`
FinishReason *string `json:"finishReason"`
Usage *generativeaiinference.Usage `json:"usage"`
Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态
Text string `json:"text"` // COHERE 流事件的顶层增量文本
// CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级)
CohereCalls []struct {
Name string `json:"name"`
Parameters interface{} `json:"parameters"`
} `json:"toolCalls"`
}
// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。
func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) {
var ev genAiStreamEvent
if err := json.Unmarshal(data, &ev); err != nil {
return aiwire.ChatChunk{}, false
}
if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析
var inner genAiStreamEvent
if json.Unmarshal(ev.Choices[0], &inner) == nil {
inner.Usage = ev.Usage
ev = inner
}
}
chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model}
choice := aiwire.ChunkChoice{}
if ev.Index != nil {
choice.Index = *ev.Index
}
choice.Delta.Role = strings.ToLower(ev.Message.Role)
var sb strings.Builder
for _, c := range ev.Message.Content {
sb.WriteString(c.Text)
}
if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态
sb.WriteString(ev.Text)
}
choice.Delta.Content = sb.String()
for i, tc := range ev.Message.ToolCalls {
d := aiwire.ToolCallDelta{Index: i, ID: tc.ID}
if tc.ID != "" {
d.Type = "function"
}
d.Function.Name = tc.Name
d.Function.Arguments = tc.Arguments
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
}
for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID
call := cohereCallToIR(tc.Name, &tc.Parameters, i)
d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"}
d.Function.Name = call.Function.Name
d.Function.Arguments = call.Function.Arguments
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
}
if r := mapFinishReason(deref(ev.FinishReason)); r != "" {
choice.FinishReason = &r
}
hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil
if hasPayload {
chunk.Choices = []aiwire.ChunkChoice{choice}
}
chunk.Usage = sdkUsageToIR(ev.Usage)
return chunk, hasPayload || chunk.Usage != nil
}
@@ -0,0 +1,496 @@
package oci
// 真实 OCI GenAI effort 探针(重建版)。
//
// 前身 genai_cache_integration_test.go 承载缓存/亲和/服务端工具等历轮调研模式,
// 调研结论均已归档(.trellis/tasks/archive/2026-07/*/research/),该文件已删除;
// 本文件重建共享基础设施,保留 effort 支持矩阵探测,并新增 responses 端点探测
// (multi-agent 模型仅允许走 Responses 面)。
//
// 运行(默认跳过,不触网):
//
// OCI_GENAI_CACHE_PROBE=effort-matrix OCI_CACHE_DB=<db 绝对路径> DATA_KEY=<主密钥> \
// OCI_CACHE_RUN_ID=<唯一标识> go test ./internal/oci/ -run TestRealGenAiEffortMatrix -v
//
// 可选:OCI_CACHE_CONFIG_ID(默认 6)、OCI_CACHE_REGION(默认 us-chicago-1)、
// OCI_EFFORT_CASES 自定义用例("model=effort" 逗号分隔;effort 后缀 "@responses"
// 表示走 /actions/v1/responses 端点,如 "xai.grok-4.20-multi-agent=low@responses")。
//
// 纪律:数据库只读打开;OCI_GO_SDK_DEBUG 必须为空;日志不落原始请求体与凭据,
// 错误消息经 OCID 遮掩后截断。
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"gorm.io/gorm"
"gorm.io/gorm/logger"
projectcrypto "oci-portal/internal/crypto"
"oci-portal/internal/model"
)
const (
effortProbeRegion = "us-chicago-1"
effortProbeLimit = int64(4 << 20)
)
var effortRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
type effortProbeCase struct {
model string
effort string
responses bool // true 走 /actions/v1/responses,否则 typed /actions/chat
stream bool // 仅 responses 面:请求 SSE 流,观测事件类型序列
}
type effortProbeResources struct {
cred Credentials
region string
modelOCID map[string]string
inference generativeaiinference.GenerativeAiInferenceClient
}
type effortProbeResult struct {
status int
requestRef string
duration time.Duration
errorCode string
errorMessage string
completionTokens int
reasoningTokens int
answerLen int
outputTypes []string
}
// effortMatrixCases 默认批为 grok-4.3 五档基线;全模型矩阵结论见任务归档,
// 复测用 OCI_EFFORT_CASES 自定义。
func effortMatrixCases() []effortProbeCase {
if raw := os.Getenv("OCI_EFFORT_CASES"); raw != "" {
return parseEffortCases(raw)
}
return []effortProbeCase{
{model: "xai.grok-4.3", effort: "none", responses: true},
{model: "xai.grok-4.3", effort: "low", responses: true},
{model: "xai.grok-4.3", effort: "high", responses: true},
}
}
// parseEffortCases 解析 "model=effort[@responses|@responses-stream]" 逗号分隔用例。
func parseEffortCases(raw string) []effortProbeCase {
var out []effortProbeCase
for _, item := range strings.Split(raw, ",") {
parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
continue
}
effort, streaming := strings.CutSuffix(parts[1], "@responses-stream")
if streaming {
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true, stream: true})
continue
}
effort, _ = strings.CutSuffix(effort, "@responses") // 后缀兼容保留;所有用例均走 responses 面
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true})
}
return out
}
func TestRealGenAiEffortMatrix(t *testing.T) {
requireEffortProbeMode(t, "effort-matrix")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
resources := newEffortProbeResources(t, ctx)
for _, tc := range effortMatrixCases() {
runEffortCase(t, ctx, resources, tc)
}
}
func requireEffortProbeMode(t *testing.T, wanted string) {
t.Helper()
if os.Getenv("OCI_GENAI_CACHE_PROBE") != wanted {
t.Skipf("set OCI_GENAI_CACHE_PROBE=%s to run", wanted)
}
if os.Getenv("OCI_GO_SDK_DEBUG") != "" {
t.Fatal("OCI_GO_SDK_DEBUG must be unset to protect signed requests")
}
if !effortRunIDPattern.MatchString(os.Getenv("OCI_CACHE_RUN_ID")) {
t.Fatal("set a unique OCI_CACHE_RUN_ID (1-64 safe characters)")
}
}
func runEffortCase(t *testing.T, ctx context.Context, resources effortProbeResources, tc effortProbeCase) {
t.Helper()
ocid, ok := resources.modelOCID[tc.model]
if !ok {
t.Logf("model=%s effort=%s resolve-failed: model not in on-demand catalog", tc.model, tc.effort)
return
}
_ = ocid
body, path, err := effortProbeBody(tc)
if err != nil {
t.Fatalf("build effort body: %v", err)
}
result := executeEffortProbe(ctx, resources, path, body)
logEffortResult(t, tc, result)
}
// effortProbeBody 构造 OpenAI Responses 直通请求体(typed chat 面已随网关剔除,
// 探针仅保留 responses 端点;历史 typed 结论见任务归档)。
func effortProbeBody(tc effortProbeCase) ([]byte, string, error) {
question := "How many positive divisors does 360 have? Answer with the number only."
body := map[string]interface{}{"model": tc.model, "input": question,
"max_output_tokens": 2048, "store": false}
if tc.effort != "" {
body["reasoning"] = map[string]string{"effort": tc.effort}
}
if tc.stream {
body["stream"] = true
}
payload, err := json.Marshal(body)
return payload, "/actions/v1/responses", err
}
// executeEffortProbe 以 IAM 签名裸 POST 指定路径;responses 面按生产实现补 compartment 头。
func executeEffortProbe(ctx context.Context, resources effortProbeResources, path string, body []byte) effortProbeResult {
client := resources.inference.BaseClient
client.Configuration.CircuitBreaker = nil
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, path, strings.NewReader(string(body)))
if err != nil {
return effortProbeResult{errorCode: "BuildRequest", errorMessage: err.Error()}
}
request.Header.Set("Content-Type", "application/json")
if path != "/actions/chat" {
request.Header.Set("CompartmentId", resources.cred.TenancyOCID)
request.Header.Set("opc-compartment-id", resources.cred.TenancyOCID)
}
return effortCallProbe(ctx, client, request)
}
func effortCallProbe(ctx context.Context, client common.BaseClient, request *http.Request) effortProbeResult {
started := time.Now()
response, err := client.Call(ctx, request)
result := effortProbeResult{duration: time.Since(started)}
if response != nil {
result.status = response.StatusCode
result.requestRef = shortEffortRef(response.Header.Get("opc-request-id"))
defer response.Body.Close()
}
if err != nil {
result.errorCode, result.errorMessage = effortProbeError(err)
return result
}
payload, readErr := io.ReadAll(io.LimitReader(response.Body, effortProbeLimit))
if readErr != nil {
result.errorCode, result.errorMessage = "ReadResponse", readErr.Error()
return result
}
parseEffortPayload(&result, payload)
return result
}
func effortProbeError(err error) (string, string) {
if serviceErr, ok := common.IsServiceError(err); ok {
return fmt.Sprintf("%d", serviceErr.GetHTTPStatusCode()), sanitizeEffortText(serviceErr.GetMessage())
}
return "CallError", sanitizeEffortText(err.Error())
}
// parseEffortPayload 兼容三种 usage 形态:typed 驼峰(chatResponse.usage.completionTokens)、
// chat 兼容面下划线(usage.completion_tokens)、responses 面(usage.output_tokens)。
func parseEffortPayload(result *effortProbeResult, payload []byte) {
if text := strings.TrimSpace(string(payload)); strings.HasPrefix(text, "event:") || strings.HasPrefix(text, "data:") {
parseEffortSSE(result, text)
return
}
var root map[string]interface{}
if json.Unmarshal(payload, &root) != nil {
return
}
result.answerLen = len(parseEffortAnswer(root))
result.outputTypes = parseEffortOutputTypes(root)
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
root = typed
}
usage, _ := root["usage"].(map[string]interface{})
for _, key := range []string{"completionTokens", "completion_tokens", "output_tokens"} {
if v, ok := effortInt(usage, key); ok {
result.completionTokens = v
break
}
}
for _, detailsKey := range []string{"completionTokensDetails", "completion_tokens_details", "output_tokens_details"} {
details, _ := usage[detailsKey].(map[string]interface{})
for _, key := range []string{"reasoningTokens", "reasoning_tokens"} {
if v, ok := effortInt(details, key); ok {
result.reasoningTokens = v
}
}
}
}
// parseEffortAnswer 提取正文文本:typed 取 chatResponse.choices[].message.content[].text,
// responses 取 output[] 里 message 项的 content[].text。仅在内存中量长度,不落日志。
func parseEffortAnswer(root map[string]interface{}) string {
var sb strings.Builder
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
choices, _ := typed["choices"].([]interface{})
for _, c := range choices {
cm, _ := c.(map[string]interface{})
msg, _ := cm["message"].(map[string]interface{})
collectEffortText(&sb, msg["content"])
}
return sb.String()
}
output, _ := root["output"].([]interface{})
for _, item := range output {
im, _ := item.(map[string]interface{})
if im["type"] == "message" {
collectEffortText(&sb, im["content"])
}
}
return sb.String()
}
func collectEffortText(sb *strings.Builder, content interface{}) {
parts, _ := content.([]interface{})
for _, p := range parts {
pm, _ := p.(map[string]interface{})
if text, ok := pm["text"].(string); ok {
sb.WriteString(text)
}
}
}
// parseEffortSSE 汇总 SSE 流:事件类型去重序列进 outputTypes,正文增量长度进 answerLen,
// 末尾 completed 事件里的 usage 进 tokens 字段。
func parseEffortSSE(result *effortProbeResult, text string) {
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
if name, ok := strings.CutPrefix(line, "event: "); ok {
if name = strings.TrimSpace(name); !seen[name] {
seen[name] = true
result.outputTypes = append(result.outputTypes, name)
}
continue
}
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var ev map[string]interface{}
if json.Unmarshal([]byte(data), &ev) != nil {
continue
}
if name, ok := ev["type"].(string); ok && !seen[name] { // 纯 data: 行流(无 event: 行)从事件体取类型
seen[name] = true
result.outputTypes = append(result.outputTypes, name)
}
if delta, ok := ev["delta"].(string); ok && strings.Contains(fmt.Sprint(ev["type"]), "output_text") {
result.answerLen += len(delta)
}
if resp, ok := ev["response"].(map[string]interface{}); ok {
usage, _ := resp["usage"].(map[string]interface{})
if v, ok := effortInt(usage, "output_tokens"); ok {
result.completionTokens = v
}
}
}
}
// parseEffortOutputTypes 收集 responses 面 output 项类型序列(multi-agent 行为观测)。
func parseEffortOutputTypes(root map[string]interface{}) []string {
output, _ := root["output"].([]interface{})
var types []string
for _, item := range output {
im, _ := item.(map[string]interface{})
if s, ok := im["type"].(string); ok {
types = append(types, s)
}
}
return types
}
func effortInt(root map[string]interface{}, key string) (int, bool) {
if root == nil {
return 0, false
}
if value, ok := root[key].(float64); ok {
return int(value), true
}
return 0, false
}
func logEffortResult(t *testing.T, tc effortProbeCase, result effortProbeResult) {
t.Helper()
endpoint := "typed-chat"
if tc.responses {
endpoint = "compat-responses"
}
t.Logf("endpoint=%s model=%s effort=%s status=%d completion=%d reasoning=%d answer_len=%d output_types=%v request=%s duration_ms=%d error=%s message=%q",
endpoint, tc.model, tc.effort, result.status, result.completionTokens, result.reasoningTokens,
result.answerLen, result.outputTypes, result.requestRef, result.duration.Milliseconds(),
result.errorCode, result.errorMessage)
}
// sanitizeEffortText 遮掩 OCID 并截断,避免错误消息携带租户可定位信息。
func sanitizeEffortText(text string) string {
text = regexp.MustCompile(`ocid1\.[a-z0-9._-]+`).ReplaceAllString(text, "ocid1.***")
if len(text) > 300 {
text = text[:300] + "..."
}
return text
}
func shortEffortRef(ref string) string {
if len(ref) > 12 {
return ref[:12]
}
return ref
}
func effortEnvOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
// newEffortProbeResources 装配凭据、区域、inference 客户端与按需模型目录。
func newEffortProbeResources(t *testing.T, ctx context.Context) effortProbeResources {
t.Helper()
cred := effortProbeCredentials(t)
region := effortEnvOr("OCI_CACHE_REGION", effortProbeRegion)
cred.Region = region
real := &RealClient{}
ic, err := real.genAiInferenceClient(cred, region)
if err != nil {
t.Fatalf("new inference client: %v", err)
}
models, err := real.ListGenAiModels(ctx, cred, region)
if err != nil {
t.Fatalf("list models: %s", sanitizeEffortText(err.Error()))
}
index := make(map[string]string, len(models))
for _, m := range models {
index[m.Name] = m.Ocid
}
return effortProbeResources{cred: cred, region: region, modelOCID: index, inference: ic}
}
// effortProbeCredentials 优先从面板数据库(只读)取渠道凭据,否则退回本地 ini 测试凭据。
func effortProbeCredentials(t *testing.T) Credentials {
t.Helper()
if dbPath := os.Getenv("OCI_CACHE_DB"); dbPath != "" {
return effortDBCredentials(t, dbPath)
}
return loadTestCredentials(t, effortEnvOr("OCI_TEST_KEY", "试用期"))
}
func effortDBCredentials(t *testing.T, dbPath string) Credentials {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=ro", dbPath)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open read-only probe database: %v", err)
}
cipher, err := projectcrypto.NewCipher(os.Getenv("DATA_KEY"))
if err != nil {
t.Fatalf("create data cipher: %v", err)
}
var config model.OciConfig
if err := db.First(&config, effortEnvOr("OCI_CACHE_CONFIG_ID", "6")).Error; err != nil {
t.Fatalf("load OCI config: %v", err)
}
return decryptEffortCredentials(t, db, cipher, config)
}
func decryptEffortCredentials(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, config model.OciConfig) Credentials {
t.Helper()
privateKey, err := cipher.DecryptString(config.PrivateKeyEnc)
if err != nil {
t.Fatalf("decrypt OCI config %d private key: %v", config.ID, err)
}
passphrase := ""
if config.PassphraseEnc != "" {
if passphrase, err = cipher.DecryptString(config.PassphraseEnc); err != nil {
t.Fatalf("decrypt OCI config %d passphrase: %v", config.ID, err)
}
}
return Credentials{TenancyOCID: config.TenancyOCID, UserOCID: config.UserOCID,
Fingerprint: config.Fingerprint, Region: config.Region, PrivateKey: privateKey,
Passphrase: passphrase, Proxy: loadEffortProxy(t, db, cipher, config.ProxyID)}
}
func loadEffortProxy(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, proxyID *uint) *ProxySpec {
t.Helper()
if proxyID == nil {
return nil
}
var proxy model.Proxy
if err := db.First(&proxy, *proxyID).Error; err != nil {
t.Fatalf("load proxy %d: %v", *proxyID, err)
}
password := ""
if proxy.PasswordEnc != "" {
decrypted, err := cipher.DecryptString(proxy.PasswordEnc)
if err != nil {
t.Fatalf("decrypt proxy %d password: %v", *proxyID, err)
}
password = decrypted
}
return &ProxySpec{Type: proxy.Type, Host: proxy.Host, Port: proxy.Port,
Username: proxy.Username, Password: password}
}
// TestParseEffortCases 断言自定义用例解析:端点后缀、空项与畸形项跳过。
func TestParseEffortCases(t *testing.T) {
got := parseEffortCases("a=low, b=high@responses ,bad,=x,c=")
want := []effortProbeCase{{model: "a", effort: "low", responses: true}, {model: "b", effort: "high", responses: true}}
if len(got) != len(want) {
t.Fatalf("parseEffortCases = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("case %d = %+v, want %+v", i, got[i], want[i])
}
}
}
// TestParseEffortPayload 断言三种 usage 形态与 output 类型序列的解析。
func TestParseEffortPayload(t *testing.T) {
cases := []struct {
name string
payload string
completion int
reasoning int
types int
}{
{"typed 驼峰", `{"chatResponse":{"choices":[{"message":{"content":[{"type":"TEXT","text":"24"}]}}],"usage":{"completionTokens":5,"completionTokensDetails":{"reasoningTokens":9}}}}`, 5, 9, 0},
{"responses 面", `{"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"24"}]}],"usage":{"output_tokens":7,"output_tokens_details":{"reasoning_tokens":3}}}`, 7, 3, 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var result effortProbeResult
parseEffortPayload(&result, []byte(tc.payload))
if result.completionTokens != tc.completion || result.reasoningTokens != tc.reasoning || len(result.outputTypes) != tc.types {
t.Fatalf("parse = %+v, want completion=%d reasoning=%d types=%d", result, tc.completion, tc.reasoning, tc.types)
}
if result.answerLen != 2 {
t.Fatalf("answerLen = %d, want 2", result.answerLen)
}
})
}
}
+127
View File
@@ -0,0 +1,127 @@
package oci
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
)
// RerankRank 是重排结果的一项:index 指向入参 documents 下标。
type RerankRank struct {
Index int
Score float64
}
// GuardrailCategory 是内容审核分类得分(OVERALL / BLOCKLIST)。
type GuardrailCategory struct {
Name string
Score float64
}
// GuardrailPiiHit 是一处 PII 命中(片段原文与位置)。
type GuardrailPiiHit struct {
Text string
Label string
Score float64
Offset int
Length int
}
// GuardrailsOutcome 汇总 ApplyGuardrails 三能力结果。
type GuardrailsOutcome struct {
Categories []GuardrailCategory
Pii []GuardrailPiiHit
PromptInjectionScore *float64
}
// GenAiRerank 实现 Client:文档重排(on-demand serving),返回按相关度排序的下标与得分。
func (c *RealClient) GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
resp, err := ic.RerankText(ctx, generativeaiinference.RerankTextRequest{
RerankTextDetails: generativeaiinference.RerankTextDetails{
CompartmentId: &cred.TenancyOCID,
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
Input: &query,
Documents: documents,
TopN: topN,
},
})
if err != nil {
return nil, fmt.Errorf("genai rerank: %w", err)
}
ranks := make([]RerankRank, 0, len(resp.DocumentRanks))
for _, r := range resp.DocumentRanks {
if r.Index == nil || r.RelevanceScore == nil {
continue
}
ranks = append(ranks, RerankRank{Index: *r.Index, Score: *r.RelevanceScore})
}
return ranks, nil
}
// GenAiApplyGuardrails 实现 Client:对单条文本执行内容审核 / PII / 提示注入三检测。
func (c *RealClient) GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
resp, err := ic.ApplyGuardrails(ctx, generativeaiinference.ApplyGuardrailsRequest{
ApplyGuardrailsDetails: generativeaiinference.ApplyGuardrailsDetails{
CompartmentId: &cred.TenancyOCID,
Input: generativeaiinference.GuardrailsTextInput{Content: common.String(text)},
GuardrailConfigs: &generativeaiinference.GuardrailConfigs{
ContentModerationConfig: &generativeaiinference.ContentModerationConfiguration{Categories: []string{"OVERALL"}},
PersonallyIdentifiableInformationConfig: &generativeaiinference.PersonallyIdentifiableInformationConfiguration{Types: []string{}},
PromptInjectionConfig: &generativeaiinference.PromptInjectionConfiguration{},
},
},
})
if err != nil {
return nil, fmt.Errorf("genai guardrails: %w", err)
}
return guardrailsOutcome(resp.Results), nil
}
// guardrailsOutcome 把 SDK 结果换算为 IR;空段置空切片,得分缺失跳过。
func guardrailsOutcome(r *generativeaiinference.GuardrailsResults) *GuardrailsOutcome {
out := &GuardrailsOutcome{}
if r == nil {
return out
}
if r.ContentModeration != nil {
for _, cat := range r.ContentModeration.Categories {
if cat.Name == nil || cat.Score == nil {
continue
}
out.Categories = append(out.Categories, GuardrailCategory{Name: *cat.Name, Score: *cat.Score})
}
}
for _, hit := range r.PersonallyIdentifiableInformation {
h := GuardrailPiiHit{}
if hit.Text != nil {
h.Text = *hit.Text
}
if hit.Label != nil {
h.Label = *hit.Label
}
if hit.Score != nil {
h.Score = *hit.Score
}
if hit.Offset != nil {
h.Offset = *hit.Offset
}
if hit.Length != nil {
h.Length = *hit.Length
}
out.Pii = append(out.Pii, h)
}
if r.PromptInjection != nil && r.PromptInjection.Score != nil {
out.PromptInjectionScore = r.PromptInjection.Score
}
return out
}
+123
View File
@@ -0,0 +1,123 @@
package oci
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
)
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
const compatResponsesLimit = int64(8 << 20)
// dispatcherWithTimeout 把 dispatcher 换成指定总超时的拷贝(保留 Transport,
// 代理链路不受影响);timeout=0 表示无总超时(流式读 body 不能有总时限)。
// 非 *http.Client 的自定义 dispatcher 保持原样,维持既有超时行为。
func dispatcherWithTimeout(d common.HTTPRequestDispatcher, timeout time.Duration) common.HTTPRequestDispatcher {
hc, ok := d.(*http.Client)
if !ok {
return d
}
return &http.Client{Transport: hc.Transport, Timeout: timeout}
}
// newCompatResponsesRequest 构造 /actions/v1/responses 直通请求。
func newCompatResponsesRequest(ctx context.Context, cred Credentials, body []byte) (*http.Request, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build compat responses request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
return request, nil
}
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
// wait 为整请求总超时:非流式上游要等全部生成完才回响应头,SDK 默认 60s 会掐断慢模型。
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, wait)
request, err := newCompatResponsesRequest(ctx, cred, body)
if err != nil {
return nil, err
}
response, err := client.Call(ctx, request)
if err != nil {
return nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
if err != nil {
return nil, fmt.Errorf("read compat responses body: %w", err)
}
return payload, nil
}
// cancelReadCloser 在流关闭时同步取消建立阶段派生的 ctx,避免其随流生命周期泄漏。
type cancelReadCloser struct {
io.ReadCloser
cancel context.CancelFunc
}
func (c *cancelReadCloser) Close() error {
c.cancel()
return c.ReadCloser.Close()
}
// httpCaller 抽象 BaseClient.Call,便于对预算逻辑做无签名单测。
type httpCaller interface {
Call(ctx context.Context, request *http.Request) (*http.Response, error)
}
// callWithHeaderBudget 以 wait 为等待响应头预算执行调用:预算内未返回则取消
// 请求(SDK Call 会把 ctx 重绑到请求);响应头到达即解除预算,之后流的生命
// 周期由 ctx 决定,返回的流 Close 时同步取消派生 ctx。
func callWithHeaderBudget(ctx context.Context, c httpCaller, req *http.Request, wait time.Duration) (io.ReadCloser, error) {
callCtx, cancel := context.WithCancel(ctx)
timer := time.AfterFunc(wait, cancel)
response, err := c.Call(callCtx, req)
timer.Stop()
if err != nil {
if response != nil && response.Body != nil {
response.Body.Close()
}
cancel()
return nil, err
}
return &cancelReadCloser{ReadCloser: response.Body, cancel: cancel}, nil
}
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
// 总超时置 0(SSE 读 body 不能有总时限);wait 以定时取消模拟等待响应头预算,
// 响应头到达即解除,此后流的生命周期完全由 ctx(下游客户端断开)决定。
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, 0)
request, err := newCompatResponsesRequest(ctx, cred, body)
if err != nil {
return nil, err
}
return callWithHeaderBudget(ctx, client, request, wait)
}
+141
View File
@@ -0,0 +1,141 @@
package oci
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
)
// staticDispatcher 是非 *http.Client 的自定义 dispatcher,用于降级分支。
type staticDispatcher struct{}
func (staticDispatcher) Do(*http.Request) (*http.Response, error) { return nil, nil }
func TestDispatcherWithTimeout(t *testing.T) {
tr := &http.Transport{}
tests := []struct {
name string
in common.HTTPRequestDispatcher
timeout time.Duration
check func(t *testing.T, out common.HTTPRequestDispatcher)
}{
{
name: "http.Client 换总超时并保留 Transport", in: &http.Client{Transport: tr, Timeout: 60 * time.Second},
timeout: 300 * time.Second,
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
hc, ok := out.(*http.Client)
if !ok || hc.Timeout != 300*time.Second || hc.Transport != tr {
t.Fatalf("期望拷贝 client 且 Timeout=300s、Transport 保留, 得到 %#v", out)
}
},
},
{
name: "timeout=0 表示无总超时", in: &http.Client{Timeout: 60 * time.Second}, timeout: 0,
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
if hc := out.(*http.Client); hc.Timeout != 0 {
t.Fatalf("期望 Timeout=0, 得到 %v", hc.Timeout)
}
},
},
{
name: "非 http.Client 原样返回", in: staticDispatcher{}, timeout: 300 * time.Second,
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
if _, ok := out.(staticDispatcher); !ok {
t.Fatalf("期望原样返回自定义 dispatcher, 得到 %#v", out)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { tt.check(t, dispatcherWithTimeout(tt.in, tt.timeout)) })
}
}
// ctxReader 模拟真实 HTTP body:请求 ctx 取消后读即失败。
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c *ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}
// fakeCaller 在 delay 后返回响应;期间 ctx 取消则按真实行为返回 ctx 错误。
type fakeCaller struct {
delay time.Duration
body string
gotCtx context.Context
failErr error
}
func (f *fakeCaller) Call(ctx context.Context, _ *http.Request) (*http.Response, error) {
f.gotCtx = ctx
if f.failErr != nil {
return nil, f.failErr
}
select {
case <-time.After(f.delay):
body := &ctxReader{ctx: ctx, r: strings.NewReader(f.body)}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body)}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestCallWithHeaderBudget(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "/actions/v1/responses", nil)
t.Run("预算内返回响应头后长读不受预算影响", func(t *testing.T) {
fc := &fakeCaller{delay: 0, body: "data: hello"}
stream, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
if err != nil {
t.Fatalf("callWithHeaderBudget = %v", err)
}
defer stream.Close()
time.Sleep(90 * time.Millisecond) // 远超预算,验证响应头到达后预算已解除
payload, err := io.ReadAll(stream)
if err != nil || string(payload) != "data: hello" {
t.Fatalf("预算解除后读流 = %q, %v; 期望完整 body", payload, err)
}
})
t.Run("超预算未返回响应头即取消", func(t *testing.T) {
fc := &fakeCaller{delay: time.Minute}
start := time.Now()
_, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
if !errors.Is(err, context.Canceled) {
t.Fatalf("期望 context.Canceled, 得到 %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("取消耗时 %v, 未受预算约束", elapsed)
}
})
t.Run("关闭流时取消派生 ctx", func(t *testing.T) {
fc := &fakeCaller{delay: 0, body: "x"}
stream, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute)
if err != nil {
t.Fatalf("callWithHeaderBudget = %v", err)
}
stream.Close()
if fc.gotCtx.Err() == nil {
t.Fatal("Close 后派生 ctx 应已取消")
}
})
t.Run("建立失败时同样取消派生 ctx", func(t *testing.T) {
fc := &fakeCaller{failErr: errors.New("boom")}
if _, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute); err == nil {
t.Fatal("期望建立失败")
}
if fc.gotCtx.Err() == nil {
t.Fatal("失败路径派生 ctx 应已取消")
}
})
}
+30
View File
@@ -38,3 +38,33 @@ func TestToGenAiModelRetiredFilter(t *testing.T) {
t.Error("正常模型应入池")
}
}
func TestDedupGenAiModelsFineTunePreference(t *testing.T) {
now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
chatFT := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat, generativeai.ModelCapabilityFineTune}
mk := func(id, name string, caps []generativeai.ModelCapabilityEnum) generativeai.ModelSummary {
return generativeai.ModelSummary{
Id: common.String(id), DisplayName: common.String(name), Vendor: common.String("meta"),
Capabilities: caps, LifecycleState: generativeai.ModelLifecycleStateActive,
}
}
const name = "meta.llama-3-70b-instruct"
tests := []struct {
name string
items []generativeai.ModelSummary
want string // 期望保留的 OCID
}{
{"基座在前、纯对话在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-ft", name, chatFT), mk("o-od", name, chat)}, "o-od"},
{"纯对话在前、基座在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-od", name, chat), mk("o-ft", name, chatFT)}, "o-od"},
{"仅基座条目:保留不误删", []generativeai.ModelSummary{mk("o-ft", name, chatFT)}, "o-ft"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dedupGenAiModels(tt.items, now)
if len(got) != 1 || got[0].Ocid != tt.want {
t.Errorf("dedupGenAiModels() = %+v, want 仅保留 %s", got, tt.want)
}
})
}
}
+44
View File
@@ -0,0 +1,44 @@
package oci
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/oracle/oci-go-sdk/v65/common"
)
// compatSpeechLimit 限制 TTS 音频响应体大小(长文本 mp3 给足余量)。
const compatSpeechLimit = int64(64 << 20)
// GenAiCompatSpeech 实现 Client:把 OpenAI Audio Speech 请求体直通到 OCI
// 兼容面 `/openai/v1/audio/speech`(IAM 签名,BasePath 置空——该面与
// /20231130/actions 面并存,实测仅前者承载 TTS)。返回音频字节与 Content-Type。
func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, "", err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
client.BasePath = ""
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/openai/v1/audio/speech", bytes.NewReader(body))
if err != nil {
return nil, "", fmt.Errorf("build compat speech request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
return nil, "", err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
if err != nil {
return nil, "", fmt.Errorf("read compat speech body: %w", err)
}
return payload, response.Header.Get("Content-Type"), nil
}
+10 -176
View File
@@ -1,193 +1,27 @@
package oci
import (
"encoding/json"
"testing"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射
func TestIrToSDK(t *testing.T) {
temp, mt := 0.7, 100
ir := aiwire.ChatRequest{
Model: "meta.llama-3.3-70b-instruct",
Messages: []aiwire.ChatMessage{
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
{Role: "user", Content: aiwire.NewTextContent("你好")},
{Role: "assistant", ToolCalls: []aiwire.ToolCall{{ID: "c1", Type: "function", Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}},
{Role: "tool", ToolCallID: "c1", Content: aiwire.NewTextContent("晴")},
},
Temperature: &temp,
MaxTokens: &mt,
Stop: aiwire.StringList{"END"},
Tools: []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}}},
ToolChoice: json.RawMessage(`"auto"`),
}
req := irToSDK(ir, true)
if len(req.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(req.Messages))
}
if _, ok := req.Messages[0].(generativeaiinference.SystemMessage); !ok {
t.Fatalf("messages[0] = %T, want SystemMessage", req.Messages[0])
}
am, ok := req.Messages[2].(generativeaiinference.AssistantMessage)
if !ok || len(am.ToolCalls) != 1 {
t.Fatalf("assistant tool calls 未映射: %T %+v", req.Messages[2], am)
}
tm, ok := req.Messages[3].(generativeaiinference.ToolMessage)
if !ok || deref(tm.ToolCallId) != "c1" {
t.Fatalf("tool message 未映射 toolCallId: %+v", tm)
}
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("temperature 未直通")
}
if req.MaxTokens == nil || *req.MaxTokens != 100 || len(req.Stop) != 1 {
t.Fatalf("maxTokens/stop 未直通")
}
if _, ok := req.ToolChoice.(generativeaiinference.ToolChoiceAuto); !ok {
t.Fatalf("toolChoice = %T, want auto", req.ToolChoice)
}
if len(req.Tools) != 1 || req.IsStream == nil || !*req.IsStream || req.StreamOptions == nil {
t.Fatalf("tools/stream 选项未映射")
}
}
// TestSdkToIR 断言响应文本、工具调用与用量的反向映射。
func TestSdkToIR(t *testing.T) {
text, fr := "东京晴", "tool_calls"
idx, pt, ct, tt := 0, 10, 5, 15
id, name, args := "c1", "get_weather", `{"city":"东京"}`
resp := generativeaiinference.GenericChatResponse{
Choices: []generativeaiinference.ChatChoice{{
Index: &idx,
Message: generativeaiinference.AssistantMessage{
Content: []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}},
ToolCalls: []generativeaiinference.ToolCall{generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}},
},
FinishReason: &fr,
}},
Usage: &generativeaiinference.Usage{PromptTokens: &pt, CompletionTokens: &ct, TotalTokens: &tt},
}
out := sdkToIR(resp, "m1")
if len(out.Choices) != 1 || out.Choices[0].Message.Content.JoinText() != "东京晴" {
t.Fatalf("文本未映射: %+v", out)
}
if out.Choices[0].FinishReason != "tool_calls" || len(out.Choices[0].Message.ToolCalls) != 1 {
t.Fatalf("finishReason/toolCalls 未映射: %+v", out.Choices[0])
}
if out.Usage == nil || out.Usage.TotalTokens != 15 {
t.Fatalf("usage 未映射: %+v", out.Usage)
}
}
// TestParseGenAiEvent 断言流事件宽容解析:文本增量、结束原因与 usage 事件。
func TestParseGenAiEvent(t *testing.T) {
chunk, ok := parseGenAiEvent([]byte(`{"index":0,"message":{"role":"ASSISTANT","content":[{"type":"TEXT","text":"你"}]}}`), "m1")
if !ok || len(chunk.Choices) != 1 || chunk.Choices[0].Delta.Content != "你" {
t.Fatalf("文本增量解析失败: %+v", chunk)
}
chunk, ok = parseGenAiEvent([]byte(`{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":2,"totalTokens":5}}`), "m1")
if !ok || chunk.Choices[0].FinishReason == nil || *chunk.Choices[0].FinishReason != "stop" {
t.Fatalf("finishReason 解析失败: %+v", chunk)
}
if chunk.Usage == nil || chunk.Usage.TotalTokens != 5 {
t.Fatalf("usage 解析失败: %+v", chunk.Usage)
}
if _, ok := parseGenAiEvent([]byte(`{}`), "m1"); ok {
t.Fatal("空事件应返回 ok=false")
}
}
// TestSdkUsageCachedTokens 断言 embed 用量映射:缓存命中挂 details,仅命中 >0 时出现
func TestSdkUsageCachedTokens(t *testing.T) {
p, c, tot, cached := 10, 5, 15, 8
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
if u.CachedTokens() != 8 {
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
if u.PromptTokens != 10 || u.CompletionTokens != 5 || u.TotalTokens != 15 {
t.Fatalf("usage 映射错误: %+v", u)
}
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
t.Error("无细分时 CachedTokens 应为 0")
if u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 8 {
t.Fatalf("cachedTokens 未映射: %+v", u.PromptTokensDetails)
}
}
func TestIrToCohereSDK(t *testing.T) {
ir := aiwire.ChatRequest{
Model: "cohere.command-r-plus",
Messages: []aiwire.ChatMessage{
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
{Role: "user", Content: aiwire.NewTextContent("第一问")},
{Role: "assistant", Content: aiwire.NewTextContent("第一答")},
{Role: "user", Content: aiwire.NewTextContent("第二问")},
},
zero := 0
if got := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &zero}}); got.PromptTokensDetails != nil {
t.Fatalf("零命中不应带 details: %+v", got.PromptTokensDetails)
}
req, err := irToCohereSDK(ir, true)
if err != nil {
t.Fatalf("irToCohereSDK: %v", err)
}
if *req.Message != "第二问" || *req.PreambleOverride != "你是助手" || len(req.ChatHistory) != 2 {
t.Errorf("拆装错误: msg=%q preamble=%v history=%d", *req.Message, req.PreambleOverride, len(req.ChatHistory))
}
if _, ok := req.ChatHistory[0].(generativeaiinference.CohereUserMessage); !ok {
t.Errorf("history[0] 应为 user: %T", req.ChatHistory[0])
}
if _, ok := req.ChatHistory[1].(generativeaiinference.CohereChatBotMessage); !ok {
t.Errorf("history[1] 应为 chatbot: %T", req.ChatHistory[1])
}
if req.IsStream == nil || !*req.IsStream {
t.Error("IsStream 未设置")
}
if req.StreamOptions == nil || req.StreamOptions.IsIncludeUsage == nil || !*req.StreamOptions.IsIncludeUsage {
t.Error("流式应默认开启 usage 回传(StreamOptions.IsIncludeUsage)")
}
// 工具定义降级为扁平参数表(仅取 JSON Schema 顶层 properties)
ir.Tools = []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{
Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string","description":"城市"}},"required":["city"]}`)}}}
req2, err := irToCohereSDK(ir, false)
if err != nil {
t.Fatalf("工具请求应支持: %v", err)
}
tool := req2.Tools[0]
if *tool.Name != "get_weather" || *tool.Description != "get_weather" {
t.Errorf("tool = %+v", tool)
}
if pd, ok := tool.ParameterDefinitions["city"]; !ok || *pd.Type != "string" || pd.IsRequired == nil || !*pd.IsRequired {
t.Errorf("param city = %+v", pd)
}
// 图片输入仍拒绝
ir.Tools = nil
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "user", Content: aiwire.NewPartsContent([]aiwire.ContentPart{
{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: "data:image/png;base64,xx"}}})})
if _, err := irToCohereSDK(ir, false); err == nil {
t.Error("图片输入应被拒绝")
}
// 无 user 消息被拒
if _, err := irToCohereSDK(aiwire.ChatRequest{Model: "cohere.x", Messages: []aiwire.ChatMessage{{Role: "system", Content: aiwire.NewTextContent("s")}}}, false); err == nil {
t.Error("无 user 消息应被拒绝")
}
}
func TestCohereSDKToIR(t *testing.T) {
text := "回答"
resp := generativeaiinference.CohereChatResponse{
Text: &text,
FinishReason: generativeaiinference.CohereChatResponseFinishReasonComplete,
}
out := cohereSDKToIR(resp, "cohere.command-r-plus")
if out.Choices[0].Message.Content.JoinText() != "回答" || out.Choices[0].FinishReason != "stop" {
t.Errorf("cohereSDKToIR = %+v", out.Choices[0])
}
}
func TestParseGenAiEventCohere(t *testing.T) {
chunk, ok := parseGenAiEvent([]byte(`{"apiFormat":"COHERE","text":"你好"}`), "cohere.command-r")
if !ok || chunk.Choices[0].Delta.Content != "你好" {
t.Errorf("cohere text 事件解析 = %+v, %v", chunk, ok)
}
chunk, ok = parseGenAiEvent([]byte(`{"apiFormat":"COHERE","finishReason":"COMPLETE","usage":{"promptTokens":3,"completionTokens":5,"totalTokens":8}}`), "cohere.command-r")
if !ok || *chunk.Choices[0].FinishReason != "stop" || chunk.Usage.TotalTokens != 8 {
t.Errorf("cohere 终帧解析 = %+v, %v", chunk, ok)
if sdkUsageToIR(nil) != nil {
t.Fatal("nil usage 应返回 nil")
}
}
+24 -4
View File
@@ -49,6 +49,7 @@ type CreateInstanceInput struct {
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
SubnetID string // 为空时自动创建 VCN 与子网
AssignPublicIP bool
ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP
AssignIpv6 bool
SSHPublicKey string // 与 RootPassword 互斥
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
@@ -189,6 +190,10 @@ func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in Cr
}
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
// 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := core.LaunchInstanceDetails{
CompartmentId: &compartmentID,
AvailabilityDomain: &in.AvailabilityDomain,
@@ -400,6 +405,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 +422,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),
+42
View File
@@ -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
+45 -2
View File
@@ -234,8 +234,12 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
if err != nil {
return RelayResource{}, err
}
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
return res, err
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID)
if err != nil {
return RelayResource{}, err
}
if ok {
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
}
details := sch.CreateServiceConnectorDetails{
DisplayName: common.String(relayConnectorName),
@@ -257,6 +261,45 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
}
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
// (如去除 LaunchInstance)后,已建链路经「一键创建」幂等调用原地更新,无需拆除重建。
func reconcileRelayCondition(ctx context.Context, sc sch.ServiceConnectorClient, id, condition string) error {
got, err := sc.GetServiceConnector(ctx, sch.GetServiceConnectorRequest{ServiceConnectorId: &id})
if err != nil {
return fmt.Errorf("get service connector: %w", err)
}
if !relayConditionDiffers(got.Tasks, condition) {
return nil
}
details := sch.UpdateServiceConnectorDetails{Tasks: []sch.TaskDetails{}}
if condition != "" {
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
}
_, err = sc.UpdateServiceConnector(ctx, sch.UpdateServiceConnectorRequest{
ServiceConnectorId: &id, UpdateServiceConnectorDetails: details,
})
if err != nil {
return fmt.Errorf("update service connector condition: %w", err)
}
return nil
}
// relayConditionDiffers 判断现有任务集与期望条件是否不一致:
// 期望形态是单条 LogRule 条件;任务数、类型或条件文本不同均视为漂移。
func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
if want == "" {
return len(tasks) > 0
}
if len(tasks) != 1 {
return true
}
rule, ok := tasks[0].(sch.LogRuleTaskDetailsResponse)
if !ok || rule.Condition == nil {
return true
}
return *rule.Condition != want
}
// findRelayConnector 按名查找存活 Connector。
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
+30
View File
@@ -3,6 +3,8 @@ package oci
import (
"strings"
"testing"
"github.com/oracle/oci-go-sdk/v65/sch"
)
func TestRelayEventCondition(t *testing.T) {
@@ -35,3 +37,31 @@ func TestRelayEventConditionQuoting(t *testing.T) {
t.Errorf("单引号数 = %d, want 2 (%s)", strings.Count(cond, "'"), cond)
}
}
func TestRelayConditionDiffers(t *testing.T) {
cond := "data.eventName='TerminateInstance'"
rule := func(c string) sch.TaskDetailsResponse {
return sch.LogRuleTaskDetailsResponse{Condition: &c}
}
tests := []struct {
name string
tasks []sch.TaskDetailsResponse
want string
diff bool
}{
{name: "条件一致不漂移", tasks: []sch.TaskDetailsResponse{rule(cond)}, want: cond, diff: false},
{name: "条件文本不同漂移", tasks: []sch.TaskDetailsResponse{rule("data.eventName='LaunchInstance'")}, want: cond, diff: true},
{name: "无任务但期望条件漂移", tasks: nil, want: cond, diff: true},
{name: "多任务漂移", tasks: []sch.TaskDetailsResponse{rule(cond), rule(cond)}, want: cond, diff: true},
{name: "期望空且无任务不漂移", tasks: nil, want: "", diff: false},
{name: "期望空但有任务漂移", tasks: []sch.TaskDetailsResponse{rule(cond)}, want: "", diff: true},
{name: "条件缺失漂移", tasks: []sch.TaskDetailsResponse{sch.LogRuleTaskDetailsResponse{}}, want: cond, diff: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := relayConditionDiffers(tt.tasks, tt.want); got != tt.diff {
t.Errorf("differs = %v, want %v", got, tt.diff)
}
})
}
}
+2 -2
View File
@@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc
return toVCN(resp.Vcn), nil
}
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 /
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
// DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 /
// 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
+707
View File
@@ -0,0 +1,707 @@
// 对象存储:namespace / 桶 / 对象 / 预签名请求(PAR)。
// 上传下载数据面走 PAR 直连 OCI,面板只做控制面;
// 例外:预览/编辑的小文件经面板中转(GetObject/PutObject),不签发 PAR。
package oci
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
)
// ErrObjectTooLarge 表示对象超出面板中转大小上限。
var ErrObjectTooLarge = errors.New("object too large for inline transfer")
// ObjectContent 是对象内容与写入所需元数据(面板中转小文件用)。
type ObjectContent struct {
Data []byte
ContentType string
Etag string
}
// Bucket 是存储桶摘要(含用量估算,来自 GetBucket 的 approximate 字段)。
type Bucket struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Visibility string `json:"visibility"` // NoPublicAccess / ObjectRead
StorageTier string `json:"storageTier"`
VersioningOn bool `json:"versioningOn"`
ApproximateCount int64 `json:"approximateCount"`
ApproximateSize int64 `json:"approximateSize"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ObjectSummary 是对象列表行;文件夹以 prefixes 单独返回。
type ObjectSummary struct {
Name string `json:"name"`
Size int64 `json:"size"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
}
// ListObjectsResult 是分页对象列表:delimiter="/" 模式,子层级归入 Prefixes。
type ListObjectsResult struct {
Objects []ObjectSummary `json:"objects"`
Prefixes []string `json:"prefixes"`
NextStartWith string `json:"nextStartWith"`
}
// ObjectDetail 是对象元数据(HEAD)。
type ObjectDetail struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
Etag string `json:"etag"`
ContentMd5 string `json:"contentMd5"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
Metadata map[string]string `json:"metadata"`
}
// PAR 是预签名请求摘要;FullURL 仅创建响应携带(OCI 事后不可再取回)。
type PAR struct {
ID string `json:"id"`
Name string `json:"name"`
ObjectName string `json:"objectName"`
AccessType string `json:"accessType"`
FullURL string `json:"fullUrl,omitempty"`
TimeExpires *time.Time `json:"timeExpires"`
TimeCreated *time.Time `json:"timeCreated"`
}
// CreateBucketInput 创建桶参数;CompartmentID 空为生效 compartment。
type CreateBucketInput struct {
Name string
CompartmentID string
PublicRead bool
StorageTier string // Standard / Archive
VersioningOn bool
}
// UpdateBucketInput 更新桶参数;nil 字段不改。
type UpdateBucketInput struct {
PublicRead *bool
VersioningOn *bool
}
// CreatePARInput 签发预签名请求参数。
type CreatePARInput struct {
Name string
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite
ExpiresHours int
}
// bulkRetryPolicy 批量删除(清空桶)的限流退避:16 并发下 OCI 可能回 429。
// 不用 DefaultRetryPolicy:其 8 次尝试、退避上限 30s,且带最终一致性模式
// (9 次、上限 45s)——大批量删除里 worker 一旦触发就长睡,吞吐塌方;
// 这里收紧为 5 次、上限 3s、关掉最终一致性,单条最坏 ~13s 后放行不拖全场。
var bulkRetryPolicy = common.NewRetryPolicyWithOptions(
common.ReplaceWithValuesFromRetryPolicy(common.DefaultRetryPolicyWithoutEventualConsistency()),
common.WithMaximumNumberAttempts(5),
common.WithExponentialBackoff(3*time.Second, 2.0),
)
func (c *RealClient) osClient(cred Credentials, region string) (objectstorage.ObjectStorageClient, error) {
oc, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider(cred))
if err != nil {
return oc, fmt.Errorf("new object storage client: %w", err)
}
applyProxy(&oc.BaseClient, cred)
if region != "" {
oc.SetRegion(normalizeRegion(region))
}
return oc, nil
}
// GetObjectStorageNamespace 实现 Client:租户 namespace(全租户唯一,常量语义),
// 首次回源后按 tenancy 进程内缓存;此前每个对象存储操作都远程取一次,
// 经代理链路时白付一整次冷连接往返。并发首次可能重复回源,结果相同无害。
func (c *RealClient) GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) {
if v, ok := c.namespaces.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
resp, err := oc.GetNamespace(ctx, objectstorage.GetNamespaceRequest{})
if err != nil {
return "", fmt.Errorf("get namespace: %w", err)
}
if cred.TenancyOCID != "" {
c.namespaces.Store(cred.TenancyOCID, deref(resp.Value))
}
return deref(resp.Value), nil
}
// ListBuckets 实现 Client:列出指定 compartment 下的桶并逐个补全用量(空为生效 compartment)。
func (c *RealClient) ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, err
}
resp, err := oc.ListBuckets(ctx, objectstorage.ListBucketsRequest{
NamespaceName: &ns,
CompartmentId: hostCompartment(cred, compartmentID),
})
if err != nil {
return nil, fmt.Errorf("list buckets: %w", err)
}
out := make([]Bucket, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, c.bucketDetail(ctx, oc, ns, deref(it.Name)))
}
return out, nil
}
// bucketDetail 取单桶详情;失败时退化为只有名字的摘要,不拖垮列表。
func (c *RealClient) bucketDetail(ctx context.Context, oc objectstorage.ObjectStorageClient, ns, name string) Bucket {
resp, err := oc.GetBucket(ctx, objectstorage.GetBucketRequest{
NamespaceName: &ns,
BucketName: &name,
Fields: []objectstorage.GetBucketFieldsEnum{"approximateCount", "approximateSize"},
})
if err != nil {
return Bucket{Name: name, Namespace: ns}
}
b := Bucket{
Name: name,
Namespace: ns,
Visibility: string(resp.PublicAccessType),
StorageTier: string(resp.StorageTier),
VersioningOn: resp.Versioning == objectstorage.BucketVersioningEnabled,
ApproximateCount: derefI64(resp.ApproximateCount),
ApproximateSize: derefI64(resp.ApproximateSize),
}
if resp.TimeCreated != nil {
b.TimeCreated = &resp.TimeCreated.Time
}
return b
}
func derefI64(p *int64) int64 {
if p != nil {
return *p
}
return 0
}
// CreateBucket 实现 Client。
func (c *RealClient) CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.CreateBucketDetails{
Name: &in.Name,
CompartmentId: hostCompartment(cred, in.CompartmentID),
PublicAccessType: objectstorage.CreateBucketDetailsPublicAccessTypeNopublicaccess,
StorageTier: objectstorage.CreateBucketDetailsStorageTierStandard,
}
if in.PublicRead {
details.PublicAccessType = objectstorage.CreateBucketDetailsPublicAccessTypeObjectread
}
if strings.EqualFold(in.StorageTier, "Archive") {
details.StorageTier = objectstorage.CreateBucketDetailsStorageTierArchive
}
if in.VersioningOn {
details.Versioning = objectstorage.CreateBucketDetailsVersioningEnabled
}
if _, err := oc.CreateBucket(ctx, objectstorage.CreateBucketRequest{
NamespaceName: &ns, CreateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("create bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, in.Name), nil
}
// UpdateBucket 实现 Client:改可见性 / 版本控制。
func (c *RealClient) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.UpdateBucketDetails{}
if in.PublicRead != nil {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeNopublicaccess
if *in.PublicRead {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeObjectread
}
}
if in.VersioningOn != nil {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningSuspended
if *in.VersioningOn {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningEnabled
}
}
if _, err := oc.UpdateBucket(ctx, objectstorage.UpdateBucketRequest{
NamespaceName: &ns, BucketName: &name, UpdateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("update bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, name), nil
}
// DeleteBucket 实现 Client:仅空桶可删,OCI 拒绝非空桶。
// ErrBucketNotEmpty 表示桶内仍有对象 / 历史版本 / 未完成分片上传,OCI 拒绝删除。
var ErrBucketNotEmpty = errors.New("bucket not empty")
func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteBucket(ctx, objectstorage.DeleteBucketRequest{
NamespaceName: &ns, BucketName: &name,
}); err != nil {
if isBucketNotEmptyErr(err) {
return fmt.Errorf("delete bucket %s: %w", name, ErrBucketNotEmpty)
}
return fmt.Errorf("delete bucket: %w", err)
}
return nil
}
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
// 两者都可经后台清空(对象版本+PAR)后重删。
func isBucketNotEmptyErr(err error) bool {
var se common.ServiceError
if !errors.As(err, &se) {
return false
}
if se.GetCode() == "BucketNotEmpty" {
return true
}
return se.GetHTTPStatusCode() == 409 &&
strings.Contains(se.GetMessage(), "Preauthenticated Request")
}
// ObjectVersion 是对象版本摘要,清空桶时逐版本删除用。
type ObjectVersion struct {
Name string `json:"name"`
VersionID string `json:"versionId"`
}
// ListObjectVersions 实现 Client:分页列出全部对象版本(未开版本控制的桶返回当前版本)。
func (c *RealClient) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListObjectVersionsRequest{
NamespaceName: &ns, BucketName: &bucket, Limit: common.Int(1000),
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListObjectVersions(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list object versions: %w", err)
}
out := make([]ObjectVersion, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, ObjectVersion{Name: deref(it.Name), VersionID: deref(it.VersionId)})
}
return out, deref(resp.OpcNextPage), nil
}
// DeleteObjectVersion 实现 Client:删除指定版本(versionID 空为当前版本)。
func (c *RealClient) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}
if versionID != "" {
req.VersionId = &versionID
}
if _, err := oc.DeleteObject(ctx, req); err != nil {
return fmt.Errorf("delete object version: %w", err)
}
return nil
}
// ListObjects 实现 Client:delimiter="/" 前缀模式;startWith 为上页游标。
func (c *RealClient) ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ListObjectsResult{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ListObjectsResult{}, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
req := objectstorage.ListObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
Delimiter: common.String("/"),
Limit: &limit,
Fields: common.String("name,size,timeModified,storageTier,archivalState"),
}
if prefix != "" {
req.Prefix = &prefix
}
if startWith != "" {
req.Start = &startWith
}
resp, err := oc.ListObjects(ctx, req)
if err != nil {
return ListObjectsResult{}, fmt.Errorf("list objects: %w", err)
}
return toListObjectsResult(resp), nil
}
func toListObjectsResult(resp objectstorage.ListObjectsResponse) ListObjectsResult {
out := ListObjectsResult{
Objects: make([]ObjectSummary, 0, len(resp.Objects)),
Prefixes: orEmpty(resp.Prefixes),
NextStartWith: deref(resp.NextStartWith),
}
for _, o := range resp.Objects {
item := ObjectSummary{
Name: deref(o.Name),
Size: derefI64(o.Size),
StorageTier: string(o.StorageTier),
ArchivalState: string(o.ArchivalState),
}
if o.TimeModified != nil {
item.TimeModified = &o.TimeModified.Time
}
out.Objects = append(out.Objects, item)
}
return out
}
// DeleteObject 实现 Client。
func (c *RealClient) DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteObject(ctx, objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
}); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
// RenameObject 实现 Client:OCI 原生重命名(同桶内)。
func (c *RealClient) RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.RenameObject(ctx, objectstorage.RenameObjectRequest{
NamespaceName: &ns,
BucketName: &bucket,
RenameObjectDetails: objectstorage.RenameObjectDetails{
SourceName: &src,
NewName: &dst,
},
}); err != nil {
return fmt.Errorf("rename object: %w", err)
}
return nil
}
// RestoreObject 实现 Client:Archive 对象取回,hours 为可下载时长(默认 24)。
func (c *RealClient) RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.RestoreObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
RestoreObjectsDetails: objectstorage.RestoreObjectsDetails{
ObjectName: &object,
},
}
if hours > 0 {
req.RestoreObjectsDetails.Hours = &hours
}
if _, err := oc.RestoreObjects(ctx, req); err != nil {
return fmt.Errorf("restore object: %w", err)
}
return nil
}
// HeadObject 实现 Client:对象元数据。
func (c *RealClient) HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectDetail{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectDetail{}, err
}
resp, err := oc.HeadObject(ctx, objectstorage.HeadObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectDetail{}, fmt.Errorf("head object: %w", err)
}
d := ObjectDetail{
Name: object,
Size: derefI64(resp.ContentLength),
ContentType: deref(resp.ContentType),
Etag: deref(resp.ETag),
ContentMd5: deref(resp.ContentMd5),
StorageTier: string(resp.StorageTier),
ArchivalState: string(resp.ArchivalState),
Metadata: resp.OpcMeta,
}
if resp.LastModified != nil {
d.TimeModified = &resp.LastModified.Time
}
return d, nil
}
// GetObject 实现 Client:整体读入对象内容;超过 maxBytes 返回 ErrObjectTooLarge。
func (c *RealClient) GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectContent{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectContent{}, err
}
resp, err := oc.GetObject(ctx, objectstorage.GetObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectContent{}, fmt.Errorf("get object: %w", err)
}
defer resp.Content.Close()
if derefI64(resp.ContentLength) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s (%d bytes): %w", object, derefI64(resp.ContentLength), ErrObjectTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return ObjectContent{}, fmt.Errorf("read object body: %w", err)
}
if int64(len(data)) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s: %w", object, ErrObjectTooLarge)
}
return ObjectContent{Data: data, ContentType: deref(resp.ContentType), Etag: deref(resp.ETag)}, nil
}
// PutObject 实现 Client:整体写入对象;ifMatch 非空时作为 If-Match 条件,
// 不匹配由 OCI 返回 412(经 ServiceError 透出)。返回新 ETag。
func (c *RealClient) PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return "", err
}
length := int64(len(data))
req := objectstorage.PutObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
ContentLength: &length,
PutObjectBody: io.NopCloser(bytes.NewReader(data)),
}
if contentType != "" {
req.ContentType = &contentType
}
if ifMatch != "" {
req.IfMatch = &ifMatch
}
resp, err := oc.PutObject(ctx, req)
if err != nil {
return "", fmt.Errorf("put object: %w", err)
}
return deref(resp.ETag), nil
}
// CreatePAR 实现 Client:签发预签名请求并拼出完整 URL(仅此时可得)。
func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return PAR{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return PAR{}, err
}
if in.ExpiresHours <= 0 {
in.ExpiresHours = 24
}
details := objectstorage.CreatePreauthenticatedRequestDetails{
Name: &in.Name,
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)},
}
if in.ObjectName != "" {
details.ObjectName = &in.ObjectName
}
// 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览
if strings.HasPrefix(in.AccessType, "AnyObject") {
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
}
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
})
if err != nil {
return PAR{}, fmt.Errorf("create par: %w", err)
}
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
}
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.AccessUri != nil {
out.FullURL = strings.TrimSuffix(endpoint, "/") + *p.AccessUri
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// ListPARsPage 实现 Client:单页列出桶内预签名请求(摘要不含 URL),
// 返回下一页游标,空串表示已到末页;limit ≤0 时用 OCI 默认页大小。
func (c *RealClient) ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListPreauthenticatedRequestsRequest{NamespaceName: &ns, BucketName: &bucket}
if limit > 0 {
req.Limit = common.Int(limit)
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListPreauthenticatedRequests(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list pars: %w", err)
}
out := make([]PAR, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, summaryPAR(it))
}
return out, deref(resp.OpcNextPage), nil
}
// ListPARs 实现 Client:翻页列出桶内全部有效预签名请求(清空/全部删除用)。
func (c *RealClient) ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) {
out := make([]PAR, 0, 16)
page := ""
for {
items, next, err := c.ListPARsPage(ctx, cred, region, bucket, page, 1000)
if err != nil {
return nil, err
}
out = append(out, items...)
if next == "" {
return out, nil
}
page = next
}
}
func summaryPAR(p objectstorage.PreauthenticatedRequestSummary) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// DeletePAR 实现 Client:撤销预签名请求,链接立即失效。
func (c *RealClient) DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeletePreauthenticatedRequest(ctx, objectstorage.DeletePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, ParId: &parID,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}); err != nil {
return fmt.Errorf("delete par: %w", err)
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package oci
import (
"context"
"testing"
)
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
// (凭据为空也能取到,证明未走回源路径)。
func TestGetObjectStorageNamespaceCached(t *testing.T) {
c := NewClient()
tests := []struct {
name string
tenancy string
seed string
}{
{name: "缓存命中直接返回", tenancy: "ocid1.tenancy.oc1..aaaa", seed: "ns-a"},
{name: "不同租户各取各的", tenancy: "ocid1.tenancy.oc1..bbbb", seed: "ns-b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c.namespaces.Store(tt.tenancy, tt.seed)
got, err := c.GetObjectStorageNamespace(context.Background(), Credentials{TenancyOCID: tt.tenancy}, "us-ashburn-1")
if err != nil || got != tt.seed {
t.Fatalf("GetObjectStorageNamespace = %q, %v; want %q", got, err, tt.seed)
}
})
}
}
+49 -5
View File
@@ -2,8 +2,10 @@ package oci
import (
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
"golang.org/x/net/proxy"
@@ -23,6 +25,27 @@ type ProxySpec struct {
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
const proxyClientTimeout = 60 * time.Second
// 阶段超时对齐 SDK 直连 Transport 模板(transport_template_provider):连不上的
// 代理快速失败,而不是拖满总超时;responses 直通去掉总超时后这是建立阶段的兜底之一。
const (
proxyDialTimeout = 30 * time.Second
proxyTLSHandshakeTimeout = 10 * time.Second
)
// 连接池参数:PerHost 须 > 批量删除并发数(service 层 16)——并发拨号竞速时
// Transport 会投机新建连接,池上限过紧会让这些连接用一次即被丢弃(churn);
// IdleConnTimeout 显式关闭空闲连接(零值=永不关,只能等远端 ~65s 断开,连接会堆积)。
const (
proxyMaxIdleConns = 128
proxyMaxIdleConnsPerHost = 32
proxyIdleConnTimeout = 90 * time.Second
)
// proxyClients 按代理配置复用 http.Client。SDK client 每次操作新建,
// 若 Transport 也随之新建则连接零复用:每个请求都要付完整的
// TCP+SOCKS/CONNECT+TLS 握手(经代理 3+ 个 RTT),批量操作被拖到分钟级。
var proxyClients sync.Map // ProxySpec(值) → *http.Client
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
func applyProxy(base *common.BaseClient, cred Credentials) {
@@ -31,16 +54,21 @@ func applyProxy(base *common.BaseClient, cred Credentials) {
}
}
// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。
// proxyHTTPClient 按代理配置取共享 http.Client;nil 或非法配置返回 nil(走直连)。
// 同配置复用同一实例(连接池随之复用);调用方只可包装、不得改写其字段。
func proxyHTTPClient(p *ProxySpec) *http.Client {
if p == nil || p.Host == "" || p.Port <= 0 {
return nil
}
if v, ok := proxyClients.Load(*p); ok {
return v.(*http.Client)
}
tr := transportFor(p)
if tr == nil {
return nil
}
return &http.Client{Transport: tr, Timeout: proxyClientTimeout}
v, _ := proxyClients.LoadOrStore(*p, &http.Client{Transport: tr, Timeout: proxyClientTimeout})
return v.(*http.Client)
}
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
@@ -49,21 +77,35 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
return proxyHTTPClient(p)
}
// pooledTransport 连接池参数统一的 Transport 骨架;阶段超时见常量注释。
func pooledTransport() *http.Transport {
return &http.Transport{
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
MaxIdleConns: proxyMaxIdleConns,
MaxIdleConnsPerHost: proxyMaxIdleConnsPerHost,
IdleConnTimeout: proxyIdleConnTimeout,
}
}
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
func transportFor(p *ProxySpec) *http.Transport {
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
dialer := &net.Dialer{Timeout: proxyDialTimeout}
if p.Type == "http" || p.Type == "https" {
u := &url.URL{Scheme: p.Type, Host: addr}
if p.Username != "" {
u.User = url.UserPassword(p.Username, p.Password)
}
return &http.Transport{Proxy: http.ProxyURL(u)}
tr := pooledTransport()
tr.Proxy = http.ProxyURL(u)
tr.DialContext = dialer.DialContext
return tr
}
var auth *proxy.Auth
if p.Username != "" {
auth = &proxy.Auth{User: p.Username, Password: p.Password}
}
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
d, err := proxy.SOCKS5("tcp", addr, auth, dialer)
if err != nil {
return nil
}
@@ -71,5 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport {
if !ok {
return nil
}
return &http.Transport{DialContext: cd.DialContext}
tr := pooledTransport()
tr.DialContext = cd.DialContext
return tr
}
+91
View File
@@ -0,0 +1,91 @@
package oci
import (
"testing"
)
func TestTransportForStageTimeouts(t *testing.T) {
tests := []struct {
name string
spec *ProxySpec
wantProxy bool // CONNECT 分支应设 Proxy 函数
}{
{name: "http CONNECT 代理", spec: &ProxySpec{Type: "http", Host: "127.0.0.1", Port: 8080}, wantProxy: true},
{name: "https CONNECT 代理", spec: &ProxySpec{Type: "https", Host: "127.0.0.1", Port: 8443}, wantProxy: true},
{name: "socks5 代理", spec: &ProxySpec{Type: "socks5", Host: "127.0.0.1", Port: 1080, Username: "u", Password: "p"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tr := transportFor(tt.spec)
if tr == nil {
t.Fatal("transportFor 返回 nil")
}
if (tr.Proxy != nil) != tt.wantProxy {
t.Fatalf("Proxy 函数存在性 = %v, 期望 %v", tr.Proxy != nil, tt.wantProxy)
}
if tr.DialContext == nil {
t.Fatal("应设置带超时的 DialContext")
}
if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout {
t.Fatalf("TLSHandshakeTimeout = %v, 期望 %v", tr.TLSHandshakeTimeout, proxyTLSHandshakeTimeout)
}
if tr.MaxIdleConnsPerHost != proxyMaxIdleConnsPerHost || tr.IdleConnTimeout != proxyIdleConnTimeout {
t.Fatalf("连接池参数未设置: PerHost=%d IdleTimeout=%v", tr.MaxIdleConnsPerHost, tr.IdleConnTimeout)
}
})
}
}
func TestProxyHTTPClientReuse(t *testing.T) {
tests := []struct {
name string
a, b *ProxySpec
wantSame bool
}{
{
name: "同配置复用同一实例",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
wantSame: true,
},
{
name: "不同配置各自实例",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.2", Port: 1080},
},
{
name: "同地址不同凭据不复用",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u1", Password: "p1"},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u2", Password: "p2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ca, cb := proxyHTTPClient(tt.a), proxyHTTPClient(tt.b)
if ca == nil || cb == nil {
t.Fatal("proxyHTTPClient 返回 nil")
}
if (ca == cb) != tt.wantSame {
t.Fatalf("实例复用 = %v, 期望 %v", ca == cb, tt.wantSame)
}
})
}
}
func TestProxyHTTPClientInvalidSpec(t *testing.T) {
tests := []struct {
name string
spec *ProxySpec
}{
{name: "nil 配置", spec: nil},
{name: "缺 host", spec: &ProxySpec{Type: "socks5", Port: 1080}},
{name: "非法端口", spec: &ProxySpec{Type: "socks5", Host: "10.0.0.1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if c := proxyHTTPClient(tt.spec); c != nil {
t.Fatalf("非法配置应返回 nil,得到 %v", c)
}
})
}
}
+60 -6
View File
@@ -3,7 +3,9 @@ package oci
import (
"context"
"fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
@@ -62,7 +64,7 @@ func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIP
}
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作
// 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新的临时 IP
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
@@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
if err != nil {
return "", err
}
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil {
if existing.Lifetime == core.PublicIpLifetimeReserved {
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
}
// ChangeVnicPublicIP 实现 Client:为指定 VNIC 的主私有 IP 更换临时公网 IP(次要网卡场景)。
func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return "", err
}
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return "", fmt.Errorf("delete old public ip: %w", err)
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
if err != nil {
return "", err
}
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
}
// replaceEphemeralPublicIP 释放私有 IP 上现有公网 IP(临时删除、保留解绑)并分配新临时 IP。
func replaceEphemeralPublicIP(ctx context.Context, vn core.VirtualNetworkClient, cred Credentials, priv core.PrivateIp) (string, error) {
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return "", err
}
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
@@ -94,6 +109,45 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
return deref(resp.IpAddress), nil
}
// detachExistingPublicIP 释放私有 IP 上已绑定的公网 IP:
// 临时 IP 直接删除;保留 IP 仅解绑(回到未分配状态,资源不删除)。
func detachExistingPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) error {
existing := lookupPublicIP(ctx, vn, privateIPID)
if existing == nil {
return nil
}
if existing.Lifetime != core.PublicIpLifetimeReserved {
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return fmt.Errorf("delete old public ip: %w", err)
}
return nil
}
_, err := vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: existing.Id,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: common.String("")},
})
if err != nil {
return fmt.Errorf("unassign reserved public ip: %w", err)
}
waitPublicIPDetached(ctx, vn, privateIPID)
return nil
}
// waitPublicIPDetached 短轮询等私有 IP 上的旧绑定释放;保留 IP 解绑为异步操作,
// 立即创建新临时 IP 可能撞上未释放的旧绑定。超时不报错,交由后续创建操作反馈。
func waitPublicIPDetached(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) {
for i := 0; i < 10; i++ {
if lookupPublicIP(ctx, vn, privateIPID) == nil {
return
}
select {
case <-ctx.Done():
return
case <-time.After(500 * time.Millisecond):
}
}
}
// primaryVnic 返回实例的主 VNIC。
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
+197
View File
@@ -0,0 +1,197 @@
package oci
import (
"context"
"fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
// ReservedIP 是保留公网 IP 摘要;绑定目标尽力反查,失败时对应字段为空。
type ReservedIP struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
IPAddress string `json:"ipAddress"`
LifecycleState string `json:"lifecycleState"`
AssignedInstanceID string `json:"assignedInstanceId"`
AssignedInstanceName string `json:"assignedInstanceName"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ListReservedIPs 实现 Client:列出区间内全部保留公网 IP(REGION 作用域);
// compartmentID 为空时用生效 compartment。
func (c *RealClient) ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return nil, err
}
cc, err := c.computeClient(cred, region)
if err != nil {
return nil, err
}
out := make([]ReservedIP, 0, 4)
var page *string
for {
resp, err := vn.ListPublicIps(ctx, core.ListPublicIpsRequest{
Scope: core.ListPublicIpsScopeRegion,
Lifetime: core.ListPublicIpsLifetimeReserved,
CompartmentId: hostCompartment(cred, compartmentID),
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list reserved ips: %w", err)
}
for i := range resp.Items {
out = append(out, c.toReservedIP(ctx, vn, cc, resp.Items[i]))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return out, nil
}
// toReservedIP 转换 SDK 对象并尽力反查绑定的实例。
func (c *RealClient) toReservedIP(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, p core.PublicIp) ReservedIP {
var created *time.Time
if p.TimeCreated != nil {
created = &p.TimeCreated.Time
}
r := ReservedIP{
ID: deref(p.Id),
DisplayName: deref(p.DisplayName),
IPAddress: deref(p.IpAddress),
LifecycleState: string(p.LifecycleState),
TimeCreated: created,
}
if p.AssignedEntityType == core.PublicIpAssignedEntityTypePrivateIp && p.AssignedEntityId != nil {
r.AssignedInstanceID, r.AssignedInstanceName = resolveIPAssignee(ctx, vn, cc, *p.AssignedEntityId)
}
return r
}
// resolveIPAssignee 由私有 IP 反查所属实例;任一步失败即放弃,不影响列表主体。
func resolveIPAssignee(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, privateIPID string) (string, string) {
priv, err := vn.GetPrivateIp(ctx, core.GetPrivateIpRequest{PrivateIpId: &privateIPID})
if err != nil || priv.VnicId == nil {
return "", ""
}
atts, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
CompartmentId: priv.CompartmentId,
VnicId: priv.VnicId,
})
if err != nil || len(atts.Items) == 0 || atts.Items[0].InstanceId == nil {
return "", ""
}
instanceID := *atts.Items[0].InstanceId
inst, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
if err != nil {
return instanceID, ""
}
return instanceID, deref(inst.DisplayName)
}
// CreateReservedIP 实现 Client:在指定 compartment 创建保留公网 IP(空为生效 compartment)。
func (c *RealClient) CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
details := core.CreatePublicIpDetails{
CompartmentId: hostCompartment(cred, compartmentID),
Lifetime: core.CreatePublicIpDetailsLifetimeReserved,
}
if displayName != "" {
details.DisplayName = &displayName
}
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{CreatePublicIpDetails: details})
if err != nil {
return ReservedIP{}, fmt.Errorf("create reserved ip: %w", err)
}
cc, err := c.computeClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
return c.toReservedIP(ctx, vn, cc, resp.PublicIp), nil
}
// AssignReservedIP 实现 Client:绑定保留 IP 到实例主私有 IP;instanceID 为空表示解绑。
// 目标私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑),地址会变更,调用方需提示用户。
func (c *RealClient) AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
target := common.String("")
if instanceID != "" {
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
target = priv.Id
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: target},
})
if err != nil {
return fmt.Errorf("assign reserved ip: %w", err)
}
return nil
}
// AssignReservedIPToVnic 实现 Client:绑定保留 IP 到指定 VNIC 的主私有 IP(次要网卡场景);
// 该私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑)。
func (c *RealClient) AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: priv.Id},
})
if err != nil {
return fmt.Errorf("assign reserved ip to vnic: %w", err)
}
return nil
}
// vnicPrimaryPrivateIP 取 VNIC 的主私有 IP。
func vnicPrimaryPrivateIP(ctx context.Context, vn core.VirtualNetworkClient, vnicID string) (core.PrivateIp, error) {
resp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: &vnicID})
if err != nil {
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
}
for _, p := range resp.Items {
if p.IsPrimary != nil && *p.IsPrimary {
return p, nil
}
}
return core.PrivateIp{}, fmt.Errorf("assign reserved ip: vnic has no primary private ip")
}
// DeleteReservedIP 实现 Client:删除保留公网 IP(已绑定的会先被 OCI 拒绝)。
func (c *RealClient) DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: &publicIPID}); err != nil {
return fmt.Errorf("delete reserved ip: %w", err)
}
return nil
}
+4
View File
@@ -26,6 +26,9 @@ type ComputeShape struct {
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
Gpus int `json:"gpus,omitempty"`
ProcessorDescription string `json:"processorDescription,omitempty"`
// QuotaNames 是该 shape 对应的配额名(与 Limits 服务 compute limit name 同名),
// 前端据此结合 limits 行判定配额与 AD 可用性。
QuotaNames []string `json:"quotaNames,omitempty"`
}
// shapeCacheEntry 是一份 shape 清单的缓存条目。
@@ -92,6 +95,7 @@ func toComputeShape(s core.Shape) ComputeShape {
BillingType: string(s.BillingType),
IsFlexible: s.OcpuOptions != nil,
ProcessorDescription: deref(s.ProcessorDescription),
QuotaNames: s.QuotaNames,
}
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
if s.Gpus != nil {
+26 -1
View File
@@ -9,7 +9,7 @@ import (
)
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
// 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
@@ -32,6 +32,9 @@ func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID s
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
return err
}
if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil {
return err
}
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
}
@@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com
}
return nil
}
// deleteVcnNsgs 删除 VCN 下全部网络安全组:遗留 NSG 会使 DeleteVcn 报
// 409 associated with NSG。仅按 VcnId 过滤,跨 compartment 的 NSG 也能覆盖;
// 子网删除后 VNIC 已不存在,NSG 不再有关联,可直接删除。
func deleteVcnNsgs(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
resp, err := vn.ListNetworkSecurityGroups(ctx, core.ListNetworkSecurityGroupsRequest{VcnId: &vcnID})
if err != nil {
return fmt.Errorf("list network security groups: %w", err)
}
for _, g := range resp.Items {
if g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminated ||
g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminating {
continue
}
if _, err := vn.DeleteNetworkSecurityGroup(ctx, core.DeleteNetworkSecurityGroupRequest{
NetworkSecurityGroupId: g.Id,
}); err != nil {
return fmt.Errorf("delete network security group %s: %w", deref(g.DisplayName), err)
}
}
return nil
}
+6
View File
@@ -31,6 +31,8 @@ type AttachVnicInput struct {
AssignPublicIP bool `json:"assignPublicIp"`
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
AssignIpv6 bool `json:"assignIpv6"`
// ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。
ReservedPublicIPID string `json:"reservedPublicIpId"`
}
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
@@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i
if err != nil {
return Vnic{}, err
}
// 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := &core.CreateVnicDetails{
SubnetId: &in.SubnetID,
AssignPublicIp: &in.AssignPublicIP,
-332
View File
@@ -1,332 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。
// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。
func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) {
ir := aiwire.ChatRequest{
Model: req.Model,
Temperature: req.Temperature,
TopP: req.TopP,
TopK: req.TopK,
Stop: req.StopSequences,
Stream: req.Stream,
Tools: anthTools(req.Tools),
ToolChoice: anthToolChoice(req.ToolChoice),
}
if req.MaxTokens > 0 {
mt := req.MaxTokens
ir.MaxTokens = &mt
}
if sys := req.SystemText(); sys != "" {
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)})
}
for _, m := range req.Messages {
msgs, err := anthMessageToIR(m)
if err != nil {
return ir, err
}
ir.Messages = append(ir.Messages, msgs...)
}
return ir, nil
}
// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息,
// image 块转为 IR image_url 部件。
func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) {
var out []aiwire.ChatMessage
var parts []aiwire.ContentPart
var toolCalls []aiwire.ToolCall
hasImage := false
for _, b := range m.Content.AllBlocks() {
switch b.Type {
case "text":
parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text})
case "image":
p, err := anthImagePart(b.Source)
if err != nil {
return nil, err
}
parts, hasImage = append(parts, p), true
case "tool_use":
toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function",
Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}})
case "tool_result":
out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID,
Content: aiwire.NewTextContent(b.ResultText())})
case "thinking", "redacted_thinking":
// 一期忽略 thinking 块
default:
return nil, ErrAiUnsupportedBlock
}
}
content := partsContent(parts, hasImage)
if hasImage || content.JoinText() != "" || len(toolCalls) > 0 {
out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls})
}
return out, nil
}
// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。
func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) {
var src struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
URL string `json:"url"`
}
if err := json.Unmarshal(source, &src); err != nil {
return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err)
}
switch src.Type {
case "base64":
if src.MediaType == "" || src.Data == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data")
}
url := "data:" + src.MediaType + ";base64," + src.Data
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil
case "url":
if src.URL == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url")
}
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil
}
return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
}
// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。
func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content {
if !hasImage {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p.Text)
}
return aiwire.NewTextContent(sb.String())
}
return aiwire.NewPartsContent(parts)
}
func anthTools(tools []aiwire.AnthTool) []aiwire.Tool {
if len(tools) == 0 {
return nil
}
out := make([]aiwire.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, aiwire.Tool{Type: "function", Function: aiwire.FunctionDef{
Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
}
return out
}
// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。
func anthToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &tc) != nil {
return nil
}
switch tc.Type {
case "auto":
return json.RawMessage(`"auto"`)
case "any":
return json.RawMessage(`"required"`)
case "tool":
b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}})
return b
case "none":
return json.RawMessage(`"none"`)
}
return nil
}
// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。
func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse {
out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if text := choice.Message.Content.JoinText(); text != "" {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text})
}
for _, tc := range choice.Message.ToolCalls {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID,
Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)})
}
out.StopReason = anthStopReason(choice.FinishReason)
}
if resp.Usage != nil {
out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens,
CacheReadInputTokens: resp.Usage.CachedTokens()}
}
return out
}
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
func argsToJSON(args string) json.RawMessage {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(map[string]string{"_raw": args})
return b
}
func anthStopReason(finish string) string {
switch finish {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
default:
return "end_turn"
}
}
// ---- Anthropic 流式状态机 ----
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
type AnthEvent struct {
Event string
Data any
}
// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列:
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
type AnthStream struct {
id, model string
started bool
blockOpen bool
blockIsTool bool
toolID string
blockIndex int
stopReason string
usage aiwire.AnthUsage
}
// NewAnthStream 构造状态机;id 为响应消息 ID。
func NewAnthStream(id, model string) *AnthStream {
return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
}
// Feed 消费一个 IR chunk,返回应立即写出的事件。
func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent {
var events []AnthEvent
if !st.started {
st.started = true
events = append(events, st.startEvent())
}
if chunk.Usage != nil {
st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens,
CacheReadInputTokens: chunk.Usage.CachedTokens()}
}
for _, choice := range chunk.Choices {
events = append(events, st.feedDelta(choice.Delta)...)
if choice.FinishReason != nil && *choice.FinishReason != "" {
st.stopReason = anthStopReason(*choice.FinishReason)
}
}
return events
}
func (st *AnthStream) startEvent() AnthEvent {
return AnthEvent{Event: "message_start", Data: map[string]any{
"type": "message_start",
"message": map[string]any{
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
"content": []any{}, "stop_reason": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
},
}}
}
// feedDelta 处理文本与工具调用增量,必要时切块。
func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent {
var events []AnthEvent
if d.Content != "" {
if !st.blockOpen || st.blockIsTool {
events = append(events, st.openBlock(false, "", "")...)
}
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "text_delta", "text": d.Content},
}})
}
for _, tc := range d.ToolCalls {
if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) {
events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...)
}
if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool {
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments},
}})
}
}
return events
}
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
var events []AnthEvent
if st.blockOpen {
events = append(events, st.closeBlockEvent())
}
st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID
st.blockIndex++
block := map[string]any{"type": "text", "text": ""}
if isTool {
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
}
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
}})
return events
}
func (st *AnthStream) closeBlockEvent() AnthEvent {
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
"type": "content_block_stop", "index": st.blockIndex,
}}
}
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
func (st *AnthStream) Finish() []AnthEvent {
var events []AnthEvent
if !st.started {
events = append(events, st.startEvent())
st.started = true
}
if st.blockOpen {
events = append(events, st.closeBlockEvent())
st.blockOpen = false
}
events = append(events,
AnthEvent{Event: "message_delta", Data: map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
"usage": map[string]int{"output_tokens": st.usage.OutputTokens},
}},
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
)
return events
}
// Usage 返回聚合到的用量(供调用日志)。
func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage }

Some files were not shown because too many files have changed in this diff Show More