Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1914880ac | ||
|
|
a95a8255bf | ||
|
|
8894330eba | ||
|
|
f51fb6c722 | ||
|
|
0614ef22af | ||
|
|
33e92a65e2 | ||
|
|
f40f2a20e8 | ||
|
|
9cfde8b702 | ||
|
|
deea8629e0 | ||
|
|
6cf9465fea | ||
|
|
882eeade1e | ||
|
|
7019d4c5a6 | ||
|
|
18e63d2dbd | ||
|
|
91999205e2 | ||
|
|
cb66567256 | ||
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e | ||
|
|
4e2bab3032 | ||
|
|
e1f8a0539c | ||
|
|
a8bde89b56 | ||
|
|
1da2197a6c | ||
|
|
79c9e4d9b9 | ||
|
|
2fea315430 | ||
|
|
b4ef98a25e | ||
|
|
0a86b5a291 | ||
|
|
9309ad1ffc | ||
|
|
81d4650f3d | ||
|
|
c7cc5616ed | ||
|
|
489cb49cb3 | ||
|
|
7706f59549 | ||
|
|
dbba1f4905 |
@@ -23,19 +23,20 @@ jobs:
|
||||
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
|
||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: 下载前端最新 dist.zip 解压进嵌入目录
|
||||
- name: 下载前端 dist.zip
|
||||
env:
|
||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
DASH_TAG=$(curl -fsSL -H "Authorization: token ${TOKEN}" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${DASH_REPO}/releases/latest" | jq -r '.tag_name')
|
||||
curl -fsSL -H "Authorization: token ${TOKEN}" \
|
||||
-o dist.zip "${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}/dist.zip"
|
||||
DASH_TAG=$(head -1 DASH_VERSION)
|
||||
BASE="${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}"
|
||||
curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip "${BASE}/dist.zip"
|
||||
curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip.sha256 "${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
|
||||
test -f internal/webui/dist/index.html
|
||||
|
||||
- name: 构建双架构二进制(artifact stage 导出)
|
||||
- name: 构建双架构二进制
|
||||
run: |
|
||||
docker buildx build --pull \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
@@ -46,8 +47,9 @@ jobs:
|
||||
mkdir -p assets
|
||||
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
|
||||
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
|
||||
(cd assets && sha256sum * > SHA256SUMS)
|
||||
|
||||
- name: 构建并推送多平台镜像(与上一步共享构建缓存)
|
||||
- name: 构建并推送多平台镜像
|
||||
env:
|
||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
@@ -65,7 +67,7 @@ jobs:
|
||||
awk -v ver="${TAG#v}" '$0 ~ "^## \\[" ver "\\]" {flag=1; next} /^## \[/ && flag {exit} flag {print}' CHANGELOG.md > release-notes.md
|
||||
[ -s release-notes.md ] || echo "Release ${TAG}" > release-notes.md
|
||||
|
||||
- name: 创建 Release 并上传二进制附件(重跑幂等,同名附件自动替换)
|
||||
- name: 创建 Release 并上传二进制附件
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.BUILD_TOKEN }}
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
env:
|
||||
BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
|
||||
DASH_DOWNLOAD: https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip
|
||||
DASH_REPO: wangdefaa/oci-portal-dash
|
||||
IMAGE: ghcr.io/wangdefaa/oci-portal
|
||||
|
||||
jobs:
|
||||
@@ -21,14 +21,18 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 下载前端最新 dist.zip 解压进嵌入目录
|
||||
- name: 下载前端 dist.zip
|
||||
run: |
|
||||
curl -fsSL -o dist.zip "$DASH_DOWNLOAD"
|
||||
DASH_TAG=$(head -1 DASH_VERSION)
|
||||
BASE="https://github.com/${DASH_REPO}/releases/download/${DASH_TAG}"
|
||||
curl -fsSL -o dist.zip "${BASE}/dist.zip"
|
||||
curl -fsSL -o dist.zip.sha256 "${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
|
||||
test -f internal/webui/dist/index.html
|
||||
|
||||
- name: 构建双架构二进制(artifact stage 导出)
|
||||
- name: 构建双架构二进制
|
||||
run: |
|
||||
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV"
|
||||
@@ -41,6 +45,7 @@ jobs:
|
||||
mkdir -p assets
|
||||
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
|
||||
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
|
||||
(cd assets && sha256sum * > SHA256SUMS)
|
||||
ls -lh assets/
|
||||
|
||||
- name: 提取 CHANGELOG 版本段作为 Release 描述
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.6.5
|
||||
0.6.7
|
||||
@@ -27,3 +27,35 @@ 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。
|
||||
- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。
|
||||
- 自建代理 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 退避),并发提速与限流保护成对出现,二者缺一不可。
|
||||
|
||||
@@ -1,54 +1,32 @@
|
||||
# Database Guidelines
|
||||
# 数据库规范
|
||||
|
||||
> Database patterns and conventions for this project.
|
||||
> GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。
|
||||
|
||||
---
|
||||
## 租户级数据删除
|
||||
|
||||
## Overview
|
||||
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
||||
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
||||
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
||||
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
||||
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
||||
|
||||
<!--
|
||||
Document your project's database conventions here.
|
||||
## 大集合谓词用子查询,禁止展开 IN 列表
|
||||
|
||||
Questions to answer:
|
||||
- What ORM/query library do you use?
|
||||
- How are migrations managed?
|
||||
- What are the naming conventions for tables/columns?
|
||||
- How do you handle transactions?
|
||||
-->
|
||||
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `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`)。
|
||||
- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。
|
||||
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
||||
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
|
||||
|
||||
(To be filled by the team)
|
||||
## Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
|
||||
|
||||
---
|
||||
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
|
||||
|
||||
## Query Patterns
|
||||
**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
|
||||
|
||||
<!-- How should queries be written? Batch operations? -->
|
||||
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
<!-- How to create and run migrations -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
<!-- Table names, column names, index names -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- Database-related mistakes your team has made -->
|
||||
|
||||
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||
## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||
|
||||
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
||||
|
||||
@@ -58,7 +36,7 @@ Questions to answer:
|
||||
|
||||
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
|
||||
|
||||
### Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
||||
## Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
||||
|
||||
```go
|
||||
// 正确:零写库条件 + 显式 NULL
|
||||
@@ -66,3 +44,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 用例。
|
||||
|
||||
@@ -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 层校验后透传。
|
||||
|
||||
@@ -31,3 +31,17 @@ 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` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
|
||||
|
||||
## 外部响应体必须限长,超限报错而非静默截断
|
||||
|
||||
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# IdP 图标上传与清理契约
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
|
||||
- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**,
|
||||
孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
```text
|
||||
POST /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
|
||||
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
|
||||
```
|
||||
|
||||
```typescript
|
||||
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
|
||||
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>
|
||||
|
||||
interface IdpIconUpload {
|
||||
url: string // 写入 IdP iconUrl 的公网地址
|
||||
fileName: string // 仅用于清理的存储标识
|
||||
}
|
||||
```
|
||||
|
||||
上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):
|
||||
|
||||
```text
|
||||
POST /storage/v1/Images multipart: file + fileName
|
||||
DELETE /storage/v1/Images query: fileName
|
||||
```
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制;
|
||||
路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。
|
||||
- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成
|
||||
`idp-icon-<32 lowercase hex><lowercase ext>`:既避免并发/迟到响应下同名互相
|
||||
覆盖,又让存储名成为不可猜测的清理凭据。
|
||||
- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg),
|
||||
不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管,
|
||||
传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起
|
||||
(2026-07 曾过度实现完整解码套件后精简)。
|
||||
- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.<allowed ext>`,
|
||||
不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
|
||||
- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon,
|
||||
用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||||
失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。
|
||||
- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP;
|
||||
回滚成功按普通失败,回滚失败/无法确认返回 `201` 和
|
||||
`setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`;
|
||||
内部原因只按 requestId 写服务端日志,不进响应。
|
||||
- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置,
|
||||
`primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | HTTP |
|
||||
| --- | --- |
|
||||
| 缺少 multipart `file`、文件为空或文件名非法 | 400 |
|
||||
| 文件本体超过 1 MiB | 413 |
|
||||
| 扩展名不支持或与文件头魔数不符 | 415 |
|
||||
| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 |
|
||||
| 上游 DELETE 返回 404 | 204 |
|
||||
| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` |
|
||||
| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 |
|
||||
|
||||
## 5. Tests / Verification Required
|
||||
|
||||
- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。
|
||||
- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游;
|
||||
DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。
|
||||
- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。
|
||||
- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支;
|
||||
后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。
|
||||
- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。
|
||||
|
||||
## 6. Wrong vs Correct
|
||||
|
||||
```typescript
|
||||
// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。
|
||||
onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId))
|
||||
|
||||
// Correct:上传时捕获原始元组,清理只按元组走,失败静默。
|
||||
pendingIcon = { ...uploaded, cfgId, domainId }
|
||||
void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {})
|
||||
```
|
||||
|
||||
反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 +
|
||||
卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验——
|
||||
复杂度与威胁模型不匹配,已于 2026-07 精简移除。
|
||||
@@ -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,8 +19,9 @@ 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 模式、查询、迁移 | 待填 |
|
||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
||||
| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 |
|
||||
| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 |
|
||||
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +32,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
||||
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
|
||||
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
|
||||
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
|
||||
- [ ] 复用已有封装,不重复造轮子(见 `../guides/code-reuse-thinking-guide.md`)
|
||||
- [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式)
|
||||
|
||||
## Quality Check
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Logging Guidelines
|
||||
|
||||
> How logging is done in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's logging conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What logging library do you use?
|
||||
- What are the log levels and when to use each?
|
||||
- What should be logged?
|
||||
- What should NOT be logged (PII, secrets)?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Log Levels
|
||||
|
||||
<!-- When to use each level: debug, info, warn, error -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging
|
||||
|
||||
<!-- Log format, required fields -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## What to Log
|
||||
|
||||
<!-- Important events to log -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Log
|
||||
|
||||
<!-- Sensitive data, PII, secrets -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
||||
|
||||
## 日志回传链路(logrelay)资源命名与描述纪律
|
||||
|
||||
- **命名派生**:Topic 前缀、IAM Policy 名、SCH Connector DisplayName 一律由 `relayResourceNames(tenancyOCID)`(见 `internal/oci/logrelay_names.go`)基于 `SHA-256(tenancyOCID)[:4]` 派生 `<8hex>-audit` / `<8hex>-audit-p`。**不得在 `logrelay.go` 里再引入品牌明文**(`oci-portal` / `ociportal` / `logs` 等),否则跨租户恒定字面量会成为 Oracle 内部风控识别「共用同一套 oci-portal」的强指纹;背景与证据分档见调研档案 `docs/oci-sdk-caller-fingerprint.md`。
|
||||
- **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。
|
||||
- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。
|
||||
- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。
|
||||
@@ -16,3 +16,18 @@
|
||||
- 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(去空白),字段顺序与内容保持原样,符合保真要求。
|
||||
|
||||
## 多字段设置接口用字段级 PATCH,不用全量 PUT
|
||||
|
||||
「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。
|
||||
|
||||
@@ -6,6 +6,26 @@
|
||||
- 断言失败时输出 `got/want` 对比,便于定位。
|
||||
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`。
|
||||
|
||||
## 上传接口边界测试
|
||||
|
||||
- “文件最大 N 字节”与 HTTP 请求体上限不是同一个契约:multipart boundary、
|
||||
part header 和文件名会额外占空间。路由级 body limit 必须为封装开销留余量,
|
||||
service 再对文件字节数执行精确上限。
|
||||
- 新增或修改上传接口时,完整路由测试至少覆盖:恰好 N 字节成功、N+1 返回
|
||||
413、空文件返回 400、伪造扩展名返回 415。
|
||||
- 内容校验深度与威胁模型匹配:管理员向自己租户上传、由上游域名托管的低价值
|
||||
资源,扩展名白名单 + 魔数嗅探即可,不引入完整解码/结构校验
|
||||
(2026-07 IdP 图标曾过度实现后精简,见 idp-icon-upload.md)。
|
||||
- 临时远端资源使用服务端随机名,测试相同客户端文件名不会复用上游对象;清理接口
|
||||
只接受该功能生成的不可猜测标识,不能因共享 `images/` 前缀删除其他业务资产。
|
||||
|
||||
## 多步远端写入测试
|
||||
|
||||
- `创建 A → 配置 B` 这类流程至少覆盖:创建失败、B 失败且 A 回滚成功、B 失败且
|
||||
A 回滚失败/状态未知。只有确认回滚成功才能返回普通失败。
|
||||
- 部分成功响应必须有稳定机器码和关联 ID,前端据此保留已被 A 引用的临时资源;
|
||||
底层上游错误只进服务端日志,测试断言不会泄露到 HTTP body。
|
||||
|
||||
## 常见坑:`:memory:` SQLite 每个连接是独立库
|
||||
|
||||
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。
|
||||
@@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连
|
||||
```
|
||||
|
||||
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
|
||||
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# Code Reuse Thinking Guide
|
||||
|
||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
||||
|
||||
When you copy-paste or rewrite existing logic:
|
||||
- Bug fixes don't propagate
|
||||
- Behavior diverges over time
|
||||
- Codebase becomes harder to understand
|
||||
|
||||
---
|
||||
|
||||
## Before Writing New Code
|
||||
|
||||
### Step 1: Search First
|
||||
|
||||
```bash
|
||||
# Search for similar function names
|
||||
grep -r "functionName" .
|
||||
|
||||
# Search for similar logic
|
||||
grep -r "keyword" .
|
||||
```
|
||||
|
||||
### Step 2: Ask These Questions
|
||||
|
||||
| Question | If Yes... |
|
||||
|----------|-----------|
|
||||
| Does a similar function exist? | Use or extend it |
|
||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
||||
| Could this be a shared utility? | Create it in the right place |
|
||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
||||
|
||||
---
|
||||
|
||||
## Common Duplication Patterns
|
||||
|
||||
### Pattern 1: Copy-Paste Functions
|
||||
|
||||
**Bad**: Copying a validation function to another file
|
||||
|
||||
**Good**: Extract to shared utilities, import where needed
|
||||
|
||||
### Pattern 2: Similar Components
|
||||
|
||||
**Bad**: Creating a new component that's 80% similar to existing
|
||||
|
||||
**Good**: Extend existing component with props/variants
|
||||
|
||||
### Pattern 3: Repeated Constants
|
||||
|
||||
**Bad**: Defining the same constant in multiple files
|
||||
|
||||
**Good**: Single source of truth, import everywhere
|
||||
|
||||
### Pattern 4: Repeated Payload Field Extraction
|
||||
|
||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
||||
|
||||
```typescript
|
||||
const description = (ev as { description?: string }).description;
|
||||
const context = (ev as { context?: ContextEntry[] }).context;
|
||||
```
|
||||
|
||||
This is duplicated contract logic even when the code is only two lines. Each
|
||||
consumer now has its own definition of what a valid payload means.
|
||||
|
||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
||||
|
||||
```typescript
|
||||
if (isThreadEvent(ev)) {
|
||||
renderThreadEvent(ev);
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
||||
shared type guard / normalizer / projection before adding a third reader.
|
||||
|
||||
---
|
||||
|
||||
## When to Abstract
|
||||
|
||||
**Abstract when**:
|
||||
- Same code appears 3+ times
|
||||
- Logic is complex enough to have bugs
|
||||
- Multiple people might need this
|
||||
|
||||
**Don't abstract when**:
|
||||
- Only used once
|
||||
- Trivial one-liner
|
||||
- Abstraction would be more complex than duplication
|
||||
|
||||
---
|
||||
|
||||
## After Batch Modifications
|
||||
|
||||
When you've made similar changes to multiple files:
|
||||
|
||||
1. **Review**: Did you catch all instances?
|
||||
2. **Search**: Run grep to find any missed
|
||||
3. **Consider**: Should this be abstracted?
|
||||
|
||||
### Reducers Should Use Exhaustive Structure
|
||||
|
||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
||||
|
||||
```typescript
|
||||
// BAD - action-specific state transitions are hard to audit
|
||||
if (action === "opened") { ... }
|
||||
else if (action === "comment") { ... }
|
||||
else if (action === "status") { ... }
|
||||
|
||||
// GOOD - one reducer owns the transition table
|
||||
switch (event.action) {
|
||||
case "opened":
|
||||
...
|
||||
return;
|
||||
case "comment":
|
||||
...
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This matters when the event log is the source of truth. A reducer is the
|
||||
documented replay model; display code and commands should not duplicate pieces
|
||||
of that replay model.
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Commit
|
||||
|
||||
- [ ] Searched for existing similar code
|
||||
- [ ] No copy-pasted logic that should be shared
|
||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
||||
- [ ] Constants defined in one place
|
||||
- [ ] Similar patterns follow same structure
|
||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Python if/elif/else Exhaustive Check
|
||||
|
||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
||||
|
||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
||||
|
||||
**Example** (`cli_adapter.py`):
|
||||
```python
|
||||
# BAD: "gemini" falls through to else, returns "claude"
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
else:
|
||||
return "claude" # gemini silently gets "claude"!
|
||||
|
||||
# GOOD: explicit branch for every platform
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
else:
|
||||
return "claude"
|
||||
```
|
||||
|
||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
||||
|
||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
||||
|
||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
||||
|
||||
**Prevention**:
|
||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
||||
|
||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
||||
|
||||
---
|
||||
|
||||
## Template File Registration (Trellis-specific)
|
||||
|
||||
When adding new files to `src/templates/trellis/scripts/`:
|
||||
|
||||
**Single registration point**: `src/templates/trellis/index.ts`
|
||||
|
||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
||||
2. Add to `getAllScripts()` Map
|
||||
|
||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
||||
|
||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
||||
|
||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
||||
|
||||
### Quick Checklist for New Scripts
|
||||
|
||||
```bash
|
||||
# After adding a new .py file, verify it's in getAllScripts():
|
||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
||||
```
|
||||
|
||||
### Template Sync Convention
|
||||
|
||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
||||
|
||||
```bash
|
||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
||||
```
|
||||
|
||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
||||
+13
-13
@@ -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]
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -11,7 +11,8 @@
|
||||
"env": {
|
||||
"DATA_KEY": "dev-data-key",
|
||||
"JWT_SECRET": "dev-jwt-secret",
|
||||
"ADMIN_PASSWORD": "admin123"
|
||||
"ADMIN_PASSWORD": "admin123",
|
||||
"GIN_MODE": "debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+264
-2
@@ -1,8 +1,270 @@
|
||||
# Changelog
|
||||
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.0.1] - 2026-07-09
|
||||
## [0.8.2]
|
||||
|
||||
### Added
|
||||
|
||||
- 租户 IAM 用户新增 API Key 管理:支持列出密钥并标注当前签名凭据、提供 OCI CLI 配置模板、由服务端生成 RSA-2048 密钥对并上传公钥(私钥仅在创建响应中返回),以及按指纹删除单把密钥;新密钥可在验证 OCI 可用后启用为面板签名凭据并加密保存,旧密钥保留,当前在用密钥禁止直接删除
|
||||
|
||||
### Changed
|
||||
|
||||
- 升级 OCI Go SDK 至 v65.121.1
|
||||
|
||||
## [0.8.1]
|
||||
|
||||
### Added
|
||||
|
||||
- 身份提供商管理新增图标上传与删除:支持 PNG / JPEG / GIF / SVG / WebP / ICO,限制 1 MB,并校验扩展名与文件内容;图片存入身份域公共存储,返回可用于 `iconUrl` 的地址及后续清理所需的 `fileName`
|
||||
|
||||
### Changed
|
||||
|
||||
- 安全设置与 OAuth Provider 配置接口由全量 `PUT` 改为字段级 `PATCH`,仅更新请求中出现的字段,避免并发编辑时回滚其他设置;密钥字段仍支持缺省沿用、空串清除
|
||||
- 对象存储桶级 PAR 新增只读 `AnyObjectRead` 与只写 `AnyObjectWrite`,有效期上限由 30 天放宽到 100 年;仅含读取能力的桶级链接开放对象列举,非法类型或有效期稳定返回 400
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复总览将不同币种成本直接相加;响应新增按币种拆分的 `series`,顶层字段保持主币种兼容口径
|
||||
- 修复抢机任务编辑把剩余台数误当目标台数、丢失已完成进度或鉴权失败计数;任务更新增加乐观并发保护,状态已变化时返回 409 而不覆盖执行结果
|
||||
- 修复对象内容保存实际受统一 1 MB 请求体限制、无法达到声明的 5 MB 上限;非空桶后台删除现会中止未完成分片上传,并合并同桶重复清空请求
|
||||
- 修复 SAML IdP 创建与启用边界:域要求主邮箱时自动补邮箱映射,JIT 后置配置失败会尝试回滚并在无法确认时返回 `setupWarning`,首次 IdP 可正确加入默认登录策略,免 MFA 规则补齐 OCI consent 字段
|
||||
- 修复 AI 网关超大非流式响应被截断后仍按成功返回、内容日志关闭后在途请求仍写入正文,以及大量 AI 日志清理 / 租户删除可能触发数据库参数上限
|
||||
|
||||
### Security
|
||||
|
||||
- 日志回传 Topic / Policy / Connector 改为按租户派生的中性名称并移除品牌描述,降低跨租户固定指纹;旧命名资源仍可识别复用
|
||||
- 租户导入在首次 OCI 请求前即校验并应用指定代理,SAML 元数据下载同样复用租户代理且非法配置失败关闭;禁用密码登录与解绑外部身份改为事务串行校验,防止并发操作导致账号失去全部登录方式
|
||||
|
||||
## [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 提升到 16:openai.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 / -shm;MySQL / 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 兼容面不承接,上游 400;cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
|
||||
|
||||
### Fixed
|
||||
|
||||
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
|
||||
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
### Added
|
||||
|
||||
- 通知渠道扩展:Webhook(Slack / 飞书 / 钉钉 / 企微预设)、ntfy、Bark、SMTP 邮件与 Telegram 五渠道并存,逐渠道推送互不影响,密钥加密落库
|
||||
- 审计告警规则:按租户 / 事件 / 资源 / 来源 IP(CIDR in|notin)组合匹配,阈值窗口计数与冷却,命中即推送模板通知
|
||||
- 撤销全部会话:`POST /auth/revoke-sessions` 使全部旧令牌立即失效,响应携带操作者的新令牌
|
||||
- 租户暂停状态识别:测活经账户 capabilities 判定 `suspended`,独立于存活 / 失效展示
|
||||
- 抢机成功通知补全实例明细:租户别名、区域别名、类型与配置、镜像、公网 IPv4 与 root 密码
|
||||
|
||||
### Changed
|
||||
|
||||
- 敏感身份操作(修改凭据、TOTP 开关、外部身份绑定 / 解绑、密码登录开关)成功后令牌版本递增:全部旧 JWT 立即失效,响应返回新令牌无感续期(原 204 改为 200 + token)
|
||||
- 管理员徽标与管理员组识别支持双候选组名(Administrators / OCI_Administrators)
|
||||
- Release 流程:前端产物版本由 `DASH_VERSION` 显式固定并做 sha256 校验,二进制附 `SHA256SUMS`;docker-compose 示例默认只绑定 `127.0.0.1`
|
||||
- README 新增「反向代理」小节(Caddy / nginx / Traefik 示例与路由前缀清单)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 删除租户改为单事务清理全部关联本地数据(任务及日志、测活 / 成本快照、回传事件、告警规则、AI 渠道、Webhook 密钥),等待在途任务执行结束,迟到写入不再残留孤儿数据
|
||||
|
||||
### Security
|
||||
|
||||
- 实例 root 密码收敛:实例详情默认掩码、点击查看,串口控制台不再提供「复制 root 密码」
|
||||
- JWT 令牌版本机制:凭据 / TOTP / 外部身份 / 登录策略变更即失效全部旧令牌
|
||||
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
|
||||
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
|
||||
|
||||
## [0.0.1]
|
||||
|
||||
首个版本:自托管 OCI 多租户管理面板后端。
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v0.8.2
|
||||
@@ -4,102 +4,311 @@
|
||||
|
||||
# OCI Portal
|
||||
|
||||
**Oracle Cloud Infrastructure 多租户管理面板**
|
||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://github.com/wangdefaa/oci-portal/releases/latest)
|
||||
[](go.mod)
|
||||
[](docker-compose.yml)
|
||||
[](LICENSE)
|
||||
|
||||
本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件)
|
||||
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||
|
||||
[AI 网关文档](docs/AI网关.md) · [OCI 调用者指纹评估](docs/OCI调用者指纹评估.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||
|
||||
</div>
|
||||
|
||||
本仓库为后端与发行仓库;前端源码位于 [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>
|
||||
|
||||
| 登录 | 租户 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| 租户 | 任务 |
|
||||
| 任务 | AI 网关 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| AI 网关 | 通知设置 |
|
||||
| 通知设置 |
|
||||
| --- |
|
||||
|  |
|
||||
|
||||
</details>
|
||||
|
||||
## 核心能力
|
||||
|
||||
| 能力域 | 覆盖范围 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| **租户与云资源** | 多 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、SMTP;AES-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 订阅回传入库,一键创建链路
|
||||
- **租户治理**:IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人
|
||||
- **安全**:JWT + bcrypt、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、系统操作审计
|
||||
- **通知**:Telegram 模板化推送(测活/抢机/成本/锁定等事件)
|
||||
```text
|
||||
浏览器 / API 客户端
|
||||
│
|
||||
▼
|
||||
┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐
|
||||
│ Vue 3 静态资源(go:embed) │
|
||||
│ /api/v1 → Gin → Service → OCI SDK │
|
||||
│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │
|
||||
│ GORM → SQLite(默认)/ MySQL / PostgreSQL(experimental) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> [!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
|
||||
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
|
||||
```
|
||||
|
||||
### 源码构建(单文件,含前端)
|
||||
本地构建显示 `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 密钥和租户凭据会经明文传输。
|
||||
|
||||
### Caddy
|
||||
|
||||
```caddyfile
|
||||
portal.example.com {
|
||||
reverse_proxy 127.0.0.1:18888
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。
|
||||
Caddy 会自动处理 WebSocket。使用 nginx、Traefik 或其他反向代理时,还需要满足:
|
||||
|
||||
## 环境变量
|
||||
- 为串行终端和 VNC 转发 WebSocket Upgrade 头
|
||||
- 为 AI 流式响应和控制台连接设置较长的读写超时,建议 `1h`
|
||||
- 整站请求体上限至少为 `10MB`;后端会继续限制面板 API 为 `1MB`、AI 网关为 `10MB`
|
||||
- 将面板内「设置 → 安全 → 真实 IP 请求头」配置为反向代理实际写入的头;系统审计、登录锁定和 IP 限速依赖它
|
||||
- 配置 `PUBLIC_URL` 或面板地址,供 OAuth 回调和 OCI 日志回传链路使用
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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 文档(生产建议按需临时开启) |
|
||||
<details>
|
||||
<summary>nginx 最小示例</summary>
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name portal.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
|
||||
|
||||
client_max_body_size 10m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18888;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。
|
||||
|
||||
## AI 网关
|
||||
|
||||
面板内置 OpenAI / Anthropic 兼容的 GenAI 网关:独立密钥鉴权(`Authorization: Bearer sk-...` 或 `x-api-key`),支持渠道分组、加权路由、熔断探测、模型黑白名单、内容日志与调用日志。
|
||||
|
||||
| 端点 | 定位 | 流式 |
|
||||
| --- | --- | --- |
|
||||
| `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` | 当前密钥可见的模型列表 | 否 |
|
||||
|
||||
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
|
||||
|
||||
## API 与配置
|
||||
|
||||
### 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`。
|
||||
- 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)
|
||||
|
||||
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
|
||||
## 致谢
|
||||
|
||||
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+44
-2
@@ -9,12 +9,15 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata,
|
||||
// 嵌入后 TZ 环境变量即可让 cron 按本地时区解释
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
// swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费)
|
||||
@@ -28,13 +31,19 @@ import (
|
||||
)
|
||||
|
||||
// @title OCI Portal API
|
||||
// @version 0.0.1
|
||||
// @version 0.8.2
|
||||
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description 登录接口返回的 JWT,格式: Bearer <token>
|
||||
func init() {
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)")
|
||||
flag.Parse()
|
||||
@@ -101,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 {
|
||||
@@ -126,6 +136,7 @@ func run() error {
|
||||
defer aiGateway.Wait()
|
||||
defer stopCleanup()
|
||||
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||
ociConfigs.SetTenantCleanupDeps(tasks)
|
||||
tasks.AttachAiGateway(aiGateway)
|
||||
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
||||
if err := tasks.Start(); err != nil {
|
||||
@@ -137,5 +148,36 @@ func run() error {
|
||||
console := service.NewConsoleService(ociConfigs)
|
||||
proxies := service.NewProxyService(db, cipher)
|
||||
defer proxies.Wait()
|
||||
return api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway).Run(cfg.Addr)
|
||||
// 「关于」页存储指标需知数据库形态
|
||||
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)
|
||||
}
|
||||
|
||||
// serveHTTP 以显式超时与请求头上限启动 HTTP 服务,并在 SIGINT/SIGTERM 时
|
||||
// 优雅关闭(10 秒排空);防慢连接(Slowloris)与超大头耗尽资源。
|
||||
// WriteTimeout 置 0:AI 网关流式响应无固定上界,WebSocket 为 hijack 连接
|
||||
// 本就不受 net/http 超时管控;读侧超时已覆盖慢请求风险。
|
||||
func serveHTTP(addr string, handler http.Handler) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
MaxHeaderBytes: 64 << 10,
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
log.Println("[info] shutting down http server")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -1,9 +1,10 @@
|
||||
services:
|
||||
oci-portal:
|
||||
image: ghcr.io/wangdefaa/oci-portal:latest
|
||||
container_name: oci-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18888:8080"
|
||||
- "127.0.0.1:18888:8080"
|
||||
environment:
|
||||
DB_PATH: /data/oci-portal.db
|
||||
DATA_KEY: ${DATA_KEY:?DATA_KEY is required}
|
||||
@@ -11,7 +12,4 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- oci-portal-data:/data
|
||||
|
||||
volumes:
|
||||
oci-portal-data:
|
||||
- ./data:/data
|
||||
|
||||
@@ -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
@@ -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 兼容接口**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[快速接入](#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)
|
||||
@@ -0,0 +1,117 @@
|
||||
<a id="top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||
|
||||
# OCI 调用者指纹评估
|
||||
|
||||
**OCI Portal 请求暴露面与多租户关联风险**
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[结论速览](#summary) · [请求暴露面](#request-signals) · [项目指纹](#portal-signals) · [关联评估](#assessment) · [核验依据](#evidence)
|
||||
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本文面向架构与隐私评估,说明 OCI Portal 调用 OCI Public API 时 Oracle
|
||||
> 可见的信息及跨租户关联风险。结论基于当前源码,不代表 Oracle 实际采用的
|
||||
> 风控规则。
|
||||
|
||||
<a id="summary"></a>
|
||||
|
||||
## 结论速览
|
||||
|
||||
> [!IMPORTANT]
|
||||
> OCI Portal 的每次 API Key 请求都会在签名的 `keyId` 中携带
|
||||
> `tenancy OCID / user OCID / fingerprint`。这能准确识别云身份,但不能单凭
|
||||
> 一次请求证明调用方是 OCI Portal。
|
||||
|
||||
| 问题 | 结论 |
|
||||
| --- | --- |
|
||||
| Oracle 能识别单次请求的租户和用户吗? | **能。**签名身份、源 IP、目标服务、操作与时间均可见 |
|
||||
| 通用 SDK 请求头能识别 OCI Portal 吗? | **不能。**默认 User-Agent、签名算法和 Go TLS 特征被大量客户端共享 |
|
||||
| Oracle 能关联多个租户共用同一实例吗? | **具备能力。**需组合出口 IP、资源元数据、固定文本和调用序列 |
|
||||
| 外部第三方能完成同等关联吗? | **通常不能。**其无法取得 Oracle 网关日志和租户内部资源元数据 |
|
||||
|
||||
<a id="request-signals"></a>
|
||||
|
||||
## 单次请求暴露面
|
||||
|
||||
当前项目统一通过 `common.NewRawConfigurationProvider` 使用 API Key:
|
||||
|
||||
```text
|
||||
Authorization: Signature version="1",
|
||||
keyId="<tenancyOCID>/<userOCID>/<fingerprint>",
|
||||
algorithm="rsa-sha256", ...
|
||||
```
|
||||
|
||||
| 信号 | 默认形态 | 区分度 |
|
||||
| --- | --- | :---: |
|
||||
| 签名身份 | `tenancy / user / fingerprint` 明文位于 `keyId` | 身份强,软件弱 |
|
||||
| User-Agent | 大多数 SDK 请求为 `Oracle-GoSDK/<版本> (...)` | 弱 |
|
||||
| SDK 客户端信息 | SDK 生成的服务请求通常带 `opc-client-info: Oracle-GoSDK/<版本>` | 弱 |
|
||||
| 请求元数据 | `Date`、`Host`、`request-target`;写请求另含 body 摘要 | 通用 |
|
||||
| 网络特征 | 源 IP、TLS / ALPN、HTTP/2 行为 | 单次弱,跨租户组合后增强 |
|
||||
| OCI 遥测头 | 显式启用 `OCI_INCLUDE_REQUEST_TELEMETRY_DATA=true` 时,SDK 操作可写 service / operation;直通请求未必携带 | 可选 |
|
||||
|
||||
项目没有构造 Instance Principal、Resource Principal 或 Session Token
|
||||
鉴权,也没有主动覆盖 User-Agent 或为 `opc-request-id` 设置固定前缀。
|
||||
`opc-client-info` 由 SDK 请求构造器写入,不含 OCI Portal 标记;手工构造的
|
||||
请求可能不带该字段。少量手工签名请求绕过 `BaseClient.prepareRequest`,
|
||||
使用 Go `net/http` 的默认 User-Agent。
|
||||
|
||||
<a id="portal-signals"></a>
|
||||
|
||||
## OCI Portal 增量指纹
|
||||
|
||||
| 信号 | 当前行为 | 关联强度 |
|
||||
| --- | --- | :---: |
|
||||
| 出口 IP | 无租户级或全局代理时,走部署环境默认出口 / NAT | **强旁证(命中时)** |
|
||||
| 日志回传资源名 | 按 tenancy 派生为 `<8hex>-audit*`,描述使用中性文案 | 弱 |
|
||||
| GenAI Responses 请求头 | 同时写入 `CompartmentId` 与 `opc-compartment-id` | 中 / 弱 |
|
||||
| API 调用序列 | 测活、账户画像、区域与资源查询存在稳定组合 | 辅助 |
|
||||
|
||||
SDK 默认 User-Agent、`opc-client-info` 与固定签名格式只说明“使用 OCI Go
|
||||
SDK”,不是项目专属信号。
|
||||
|
||||
<a id="assessment"></a>
|
||||
|
||||
## 多租户关联评估
|
||||
|
||||
| 观察视角 | 结论 | 主要依据 |
|
||||
| --- | --- | --- |
|
||||
| Oracle 内部,跨租户数据 | **中** | 固定文本、出口 IP 与调用序列可组合比对 |
|
||||
| Oracle 内部,单租户数据 | **低** | 能识别实现痕迹,但难证明多个租户共用同一实例 |
|
||||
| 仅看 SDK 请求头 | **低** | 可识别云身份和 SDK 类型,不能可靠识别具体应用 |
|
||||
| 外部第三方 | **通常不可行** | 缺少 OCI 网关日志、Sign-on Policy 与租户资源元数据 |
|
||||
|
||||
这些等级表达的是“可关联性”,不是 Oracle 已执行关联或据此采取处置。
|
||||
同一出口 IP 也可能来自 NAT、代理或共享基础设施,必须与其他信号联合判断。
|
||||
|
||||
<a id="evidence"></a>
|
||||
|
||||
## 核验依据
|
||||
|
||||
### OCI 与 SDK
|
||||
|
||||
- [OCI Request Signatures](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm)
|
||||
- [OCI API Signing Key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm)
|
||||
- [OCI Go SDK client.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go)
|
||||
- [OCI Go SDK http_signer.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go)
|
||||
- [OCI Go SDK configuration.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/configuration.go)
|
||||
|
||||
### 项目源码
|
||||
|
||||
- [API Key provider](../internal/oci/client.go#L237-L246)
|
||||
- [手工签名请求](../internal/oci/account.go#L83-L97)
|
||||
- [租户出站代理](../internal/oci/proxyhttp.go#L45-L65)
|
||||
- [日志回传资源命名](../internal/oci/logrelay_names.go#L20-L55)
|
||||
- [GenAI Responses 请求头](../internal/oci/genai_responses.go#L27-L38)
|
||||
- [MFA justification](../internal/oci/signon.go#L223-L236)
|
||||
- [审计与日志回传约束](../.trellis/spec/backend/oci-audit.md)
|
||||
|
||||
<p align="right"><a href="#top">返回顶部 ↑</a></p>
|
||||
+6224
-333
File diff suppressed because it is too large
Load Diff
+6224
-333
File diff suppressed because it is too large
Load Diff
+4126
-330
File diff suppressed because it is too large
Load Diff
@@ -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.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
|
||||
@@ -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.1 h1:nXrE3r1qmBULT6Zp2koM2bJGDvsFtMX/be/NV9MS3Bw=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.121.1/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=
|
||||
|
||||
@@ -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 或块数组)拍平为文本。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package aiwire
|
||||
|
||||
// AnyJSON 仅供 swagger 文档渲染:经 .swaggo 全局替换,代表任意 JSON 值
|
||||
// (json.RawMessage 与 string/数组联合类型),运行时代码不使用。
|
||||
type AnyJSON struct{}
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !unix
|
||||
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
|
||||
func processCPUTime() time.Duration { return 0 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.AiContentLog]
|
||||
// @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.AiCallLog]
|
||||
// @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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -18,10 +18,11 @@ type authHandler struct {
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
// 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
|
||||
Username string `json:"username" binding:"required,max=64"`
|
||||
Password string `json:"password" binding:"required,max=128"`
|
||||
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
||||
TotpCode string `json:"totpCode"`
|
||||
TotpCode string `json:"totpCode" binding:"max=8"`
|
||||
}
|
||||
|
||||
// login 校验用户名密码(与可选 TOTP)后签发 JWT。
|
||||
@@ -31,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
|
||||
@@ -64,7 +65,7 @@ func (h *authHandler) login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.recordLogin(c, req.Username, http.StatusOK, start)
|
||||
|
||||
+88
-29
@@ -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) {
|
||||
@@ -61,7 +65,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||
// @Summary 激活两步验证
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{code: 6 位验证码}"
|
||||
// @Success 204 "已启用"
|
||||
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
|
||||
// @Success 204 "已启用但新 token 签发失败,需重新登录"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/totp/activate [post]
|
||||
func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
@@ -81,7 +86,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
h.respondFreshToken(c)
|
||||
}
|
||||
|
||||
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||
@@ -89,7 +94,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
// @Summary 停用两步验证
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{password 或 code 任一确认}"
|
||||
// @Success 204 "已停用"
|
||||
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
|
||||
// @Success 204 "已停用但新 token 签发失败,需重新登录"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/totp/disable [post]
|
||||
func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||
@@ -110,7 +116,34 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
h.respondFreshToken(c)
|
||||
}
|
||||
|
||||
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
|
||||
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录。
|
||||
func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
||||
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
|
||||
if err != nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||
}
|
||||
|
||||
// revokeSessions 撤销全部会话(令牌版本递增),并为当前操作者重签新令牌。
|
||||
//
|
||||
// @Summary 撤销全部会话
|
||||
// @Tags 认证
|
||||
// @Success 200 {object} tokenResponse "新 token 与 expiresAt"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/revoke-sessions [post]
|
||||
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
||||
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||
}
|
||||
|
||||
// ---- 登录凭据(JWT 组内) ----
|
||||
@@ -119,7 +152,7 @@ func (h *authxHandler) totpDisable(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) {
|
||||
@@ -139,8 +172,9 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||
// @Summary 修改登录凭据
|
||||
// @Tags 认证
|
||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||
// @Success 204 "已更新,请重新登录"
|
||||
// @Failure 401 {object} map[string]string "当前密码错误"
|
||||
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
|
||||
// @Success 204 "已更新但新 token 签发失败,需重新登录"
|
||||
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/credentials [put]
|
||||
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
@@ -149,7 +183,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
|
||||
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
|
||||
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -162,7 +196,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Set(usernameKey, finalName)
|
||||
h.respondFreshToken(c)
|
||||
}
|
||||
|
||||
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||
@@ -170,8 +205,9 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
// @Summary 密码登录开关
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{disabled: bool}"
|
||||
// @Success 204 "已保存"
|
||||
// @Failure 409 {object} map[string]string "未绑定外部身份"
|
||||
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
|
||||
// @Success 204 "已保存但新 token 签发失败,需重新登录"
|
||||
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/password-login [put]
|
||||
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||
@@ -191,7 +227,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
h.respondFreshToken(c)
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
@@ -202,7 +238,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())
|
||||
@@ -219,7 +255,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")
|
||||
@@ -254,7 +290,7 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
||||
if len(header) < 8 || header[:7] != "Bearer " {
|
||||
return "", false
|
||||
}
|
||||
username, err := h.auth.ParseToken(header[7:])
|
||||
username, err := h.auth.ParseToken(c.Request.Context(), header[7:])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -271,10 +307,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"
|
||||
@@ -282,15 +320,34 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
// 绑定模式:回设置页安全 tab
|
||||
c.Redirect(http.StatusFound, "/settings?oauth=bound")
|
||||
h.recordOauth(c, username, http.StatusOK, "", start)
|
||||
if mode == "bind" {
|
||||
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||
return
|
||||
}
|
||||
// 登录模式:token 放 fragment,不进服务端日志与 Referer
|
||||
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} {
|
||||
@@ -305,7 +362,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) {
|
||||
@@ -322,8 +379,9 @@ func (h *authxHandler) identities(c *gin.Context) {
|
||||
// @Summary 解绑外部身份
|
||||
// @Tags 认证
|
||||
// @Param id path int true "身份 ID"
|
||||
// @Success 204 "已解绑"
|
||||
// @Failure 409 {object} map[string]string "最后一个身份不可解绑"
|
||||
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
|
||||
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
|
||||
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/identities/{id} [delete]
|
||||
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||
@@ -340,7 +398,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
h.respondFreshToken(c)
|
||||
}
|
||||
|
||||
// ---- OAuth provider 设置(JWT 组内) ----
|
||||
@@ -361,14 +419,15 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
||||
// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
|
||||
// secret 传空串清除、缺省沿用。
|
||||
//
|
||||
// @Summary 保存 OAuth provider 配置
|
||||
// @Summary 部分更新 OAuth provider 配置
|
||||
// @Tags 设置
|
||||
// @Param body body service.UpdateOAuthInput true "provider 配置"
|
||||
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
|
||||
// @Success 200 {object} service.OAuthProvidersView
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/oauth [put]
|
||||
// @Router /api/v1/settings/oauth [patch]
|
||||
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||
var req service.UpdateOAuthInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
+159
-16
@@ -1,13 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
|
||||
type idpIconResponse struct {
|
||||
URL string `json:"url"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
type idpSetupWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ResourceCreated bool `json:"resourceCreated"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type createIdpResponse struct {
|
||||
oci.IdentityProviderInfo
|
||||
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
|
||||
}
|
||||
|
||||
// @Summary 上传身份提供商图标
|
||||
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
|
||||
// @Tags 租户 IAM
|
||||
// @Accept multipart/form-data
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
|
||||
// @Success 200 {object} idpIconResponse
|
||||
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
|
||||
// @Failure 413 {object} map[string]string "文件超过 1MB"
|
||||
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
|
||||
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
status, msg := iconFormStatus(err)
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
|
||||
if err != nil {
|
||||
status, msg := iconFormStatus(err)
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
|
||||
if err != nil {
|
||||
respondIdpIconErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
|
||||
}
|
||||
|
||||
// @Summary 删除身份提供商图标
|
||||
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
|
||||
// @Success 204 "无内容"
|
||||
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
|
||||
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
|
||||
respondIdpIconErr(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
|
||||
func iconFormStatus(err error) (int, string) {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
|
||||
}
|
||||
return http.StatusBadRequest, "缺少文件字段 file"
|
||||
}
|
||||
|
||||
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||
func respondIdpIconErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrIdpIconTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
|
||||
case errors.Is(err, service.ErrIdpIconBadType):
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
|
||||
case errors.Is(err, service.ErrIdpIconEmpty):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
|
||||
case errors.Is(err, service.ErrIdpIconBadName):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||
|
||||
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||
@@ -40,7 +148,8 @@ func boolOr(v *bool, def bool) bool {
|
||||
// @Summary 身份提供商列表
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {array} oci.IdentityProviderInfo
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
||||
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||
@@ -48,7 +157,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idps, err := h.svc.IdentityProviders(c.Request.Context(), id)
|
||||
idps, err := h.svc.IdentityProviders(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -57,10 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||
}
|
||||
|
||||
// @Summary 创建身份提供商(SAML)
|
||||
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
|
||||
// @Tags 租户 IAM
|
||||
// @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} createIdpResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
@@ -73,7 +184,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{
|
||||
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, c.Query("domainId"), oci.CreateIdpInput{
|
||||
Name: req.Name,
|
||||
Metadata: req.Metadata,
|
||||
Description: req.Description,
|
||||
@@ -87,19 +198,46 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||
JitMapEmail: req.JitMapEmail,
|
||||
})
|
||||
if err != nil {
|
||||
respondCreateIdpResult(c, idp, err)
|
||||
}
|
||||
|
||||
func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) {
|
||||
if err == nil {
|
||||
c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp})
|
||||
return
|
||||
}
|
||||
var partial *oci.PartialIdentityProviderCreateError
|
||||
if !errors.As(err, &partial) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, idp)
|
||||
requestID := logPartialIdpCreate(c, partial)
|
||||
c.JSON(http.StatusCreated, createIdpResponse{
|
||||
IdentityProviderInfo: partial.IdentityProvider,
|
||||
SetupWarning: &idpSetupWarning{
|
||||
Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查",
|
||||
ResourceCreated: true, RequestID: requestID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string {
|
||||
requestID := newRequestID()
|
||||
detail := errors.Unwrap(partial)
|
||||
if detail == nil {
|
||||
detail = partial
|
||||
}
|
||||
log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail)
|
||||
return requestID
|
||||
}
|
||||
|
||||
// @Summary 激活身份提供商
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @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) {
|
||||
@@ -114,7 +252,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled)
|
||||
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId"), *req.Enabled)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -125,6 +263,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||
// @Summary 删除身份提供商
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param idpId path string true "idpId"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
@@ -134,7 +273,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil {
|
||||
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -144,7 +283,8 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
||||
// @Summary 下载 SAML 元数据
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {file} file "SAML 元数据 XML(附件下载)"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
||||
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||
@@ -152,7 +292,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id)
|
||||
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -164,7 +304,8 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||
// @Summary 单点登录规则列表
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {array} oci.SignOnRuleInfo
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
||||
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||
@@ -172,7 +313,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id)
|
||||
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -183,8 +324,9 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||
// @Summary 创建 MFA 豁免规则
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -199,7 +341,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID)
|
||||
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, c.Query("domainId"), req.IdentityProviderID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -210,6 +352,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||
// @Summary 删除 MFA 豁免规则
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param ruleId path string true "ruleId"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
@@ -219,7 +362,7 @@ func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil {
|
||||
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Query("domainId"), c.Param("ruleId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"oci-portal/internal/crypto"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
|
||||
t.Helper()
|
||||
router, auth, logs, db := newTestRouterDB(t)
|
||||
id := seedIconConfig(t, db)
|
||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
return router, token, id, logs
|
||||
}
|
||||
|
||||
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
|
||||
t.Helper()
|
||||
cipher, err := crypto.NewCipher("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt key: %v", err)
|
||||
}
|
||||
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
|
||||
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
|
||||
if err := db.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
return cfg.ID
|
||||
}
|
||||
|
||||
func iconSVG(t *testing.T, size int) []byte {
|
||||
t.Helper()
|
||||
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
if len(data) > size {
|
||||
t.Fatalf("svg size %d exceeds target %d", len(data), size)
|
||||
}
|
||||
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
|
||||
}
|
||||
|
||||
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
_, _ = part.Write(data)
|
||||
_ = writer.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||
cases := []struct {
|
||||
name, fileName string
|
||||
data []byte
|
||||
want int
|
||||
}{
|
||||
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
|
||||
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
|
||||
{"empty", "icon.png", nil, http.StatusBadRequest},
|
||||
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteIdpIconRoute(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
|
||||
query := url.Values{"fileName": {fileName}}
|
||||
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid delete status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
responder(ctx, err)
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdpIconErrorResponder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"empty icon", service.ErrIdpIconEmpty, 400},
|
||||
{"bad icon name", service.ErrIdpIconBadName, 400},
|
||||
{"large icon", service.ErrIdpIconTooLarge, 413},
|
||||
{"bad icon type", service.ErrIdpIconBadType, 415},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
|
||||
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
|
||||
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
|
||||
partial := &oci.PartialIdentityProviderCreateError{
|
||||
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
|
||||
}
|
||||
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
|
||||
warning, ok := body["setupWarning"].(map[string]interface{})
|
||||
if rec.Code != http.StatusCreated || !ok {
|
||||
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
|
||||
}
|
||||
requestID, _ := warning["requestId"].(string)
|
||||
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
|
||||
t.Errorf("warning = %#v, want stable partial-create contract", warning)
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
|
||||
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
|
||||
respondCreateIdpResult(ctx, idp, err)
|
||||
body := map[string]interface{}{}
|
||||
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
|
||||
t.Fatalf("decode response: %v", decodeErr)
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func TestPARErrorResponder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"bad PAR type", service.ErrPARInvalidAccessType},
|
||||
{"bad PAR expiry", service.ErrPARInvalidExpiration},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARValidationReturns400(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
|
||||
cases := []struct {
|
||||
name, body string
|
||||
}{
|
||||
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
|
||||
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
|
||||
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +7,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "oci-portal/internal/model" // swagger 注解引用
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
@@ -20,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) {
|
||||
@@ -45,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) {
|
||||
@@ -87,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) {
|
||||
@@ -111,7 +113,7 @@ func (h *logEventHandler) list(c *gin.Context) {
|
||||
// @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) {
|
||||
@@ -132,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) {
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
||||
const usernameKey = "username"
|
||||
|
||||
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
|
||||
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
||||
// 通过后把用户名放进上下文。
|
||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
@@ -23,7 +24,7 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
username, err := auth.ParseToken(token)
|
||||
username, err := auth.ParseToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
@@ -33,6 +34,17 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
|
||||
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
|
||||
func bodyLimit(n int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Body != nil {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
||||
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||
|
||||
+16
-16
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ---- 对象存储 ----
|
||||
|
||||
// @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 仅创建响应返回,请立即保存"
|
||||
// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法"
|
||||
// @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 {
|
||||
respondPARError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, par)
|
||||
}
|
||||
|
||||
func respondPARError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrPARInvalidAccessType):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"})
|
||||
case errors.Is(err, service.ErrPARInvalidExpiration):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 200 {object} map[string]int "all=1 时返回 {deleted: n}"
|
||||
// @Success 204 "按 parId 删单条成功,链接立即失效"
|
||||
// @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)
|
||||
}
|
||||
+36
-17
@@ -1,13 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"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"
|
||||
@@ -36,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) {
|
||||
@@ -68,13 +73,13 @@ 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) {
|
||||
items, err := h.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
@@ -83,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) {
|
||||
@@ -102,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) {
|
||||
@@ -136,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) {
|
||||
@@ -168,6 +173,7 @@ func (h *ociConfigHandler) update(c *gin.Context) {
|
||||
}
|
||||
|
||||
// @Summary 删除租户配置
|
||||
// @Description 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
|
||||
// @Tags 租户配置
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 204 "无内容"
|
||||
@@ -190,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) {
|
||||
@@ -212,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) {
|
||||
@@ -233,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) {
|
||||
@@ -258,17 +264,29 @@ func pathID(c *gin.Context) (uint, bool) {
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// respondError 统一出错响应边界(S-08):OCI 服务错误经脱敏透出,记录缺失
|
||||
// 映射 404;其余按内部错误处理——响应只含固定文案与关联 ID,完整原因
|
||||
// (可能携带 SQL / DSN / 路径等内部细节)仅写服务端日志,经同一 ID 对应。
|
||||
func respondError(c *gin.Context, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
var svcErr common.ServiceError
|
||||
if errors.As(err, &svcErr) {
|
||||
respondOCIError(c, err, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||||
return
|
||||
}
|
||||
id := newRequestID()
|
||||
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
|
||||
}
|
||||
|
||||
// newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码,
|
||||
@@ -278,9 +296,10 @@ func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) {
|
||||
if status < http.StatusBadRequest {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
// 面板的 401 专属本地 JWT 失效(前端收到即登出);
|
||||
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出
|
||||
if status == http.StatusUnauthorized {
|
||||
// 面板的 401 专属本地 JWT 失效(前端收到即登出)、429 专属本地 IP 限速
|
||||
// (前端收到即跳 /blocked);上游 OCI 的同码是代理调用被拒/云端限流,
|
||||
// 均改用 502 透出,body 保留 ociCode 供辨识
|
||||
if status == http.StatusUnauthorized || status == http.StatusTooManyRequests {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
body := gin.H{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// fakeServiceError 模拟 oci-go-sdk 的 common.ServiceError,驱动 respondOCIError 分支。
|
||||
type fakeServiceError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e fakeServiceError) Error() string {
|
||||
return fmt.Sprintf("Error returned by service. Http Status Code: %d. Error Code: %s. Message: %s",
|
||||
e.status, e.code, e.message)
|
||||
}
|
||||
|
||||
func (e fakeServiceError) GetHTTPStatusCode() int { return e.status }
|
||||
func (e fakeServiceError) GetMessage() string { return e.message }
|
||||
func (e fakeServiceError) GetCode() string { return e.code }
|
||||
func (e fakeServiceError) GetOpcRequestID() string { return "req-1" }
|
||||
|
||||
// TestRespondErrorOCIStatus 覆盖上游状态码改写规则:
|
||||
// 401/429 在前端有本地专属语义(登出 / 跳 /blocked),上游同码须改写 502。
|
||||
func TestRespondErrorOCIStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
name string
|
||||
upstream int
|
||||
wantStatus int
|
||||
}{
|
||||
{"404 原样透传", 404, 404},
|
||||
{"409 原样透传", 409, 409},
|
||||
{"上游 401 改写 502", 401, 502},
|
||||
{"上游 429 改写 502", 429, 502},
|
||||
{"非错误码兜底 502", 200, 502},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
err := fmt.Errorf("获取流量: %w", fakeServiceError{tc.upstream, "TooManyRequests", "slow down"})
|
||||
respondError(c, err)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,6 +6,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "oci-portal/internal/service" // swagger 注解引用
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
@@ -13,13 +15,13 @@ 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) {
|
||||
regions, err := oci.AllRegions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, regions)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -21,7 +21,8 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
||||
accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
|
||||
r.Use(accessLog, RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
|
||||
v1 := r.Group("/api/v1", bodyLimit(1<<20))
|
||||
registerAuthPublic(v1, auth, oauth, systemLogs)
|
||||
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||
registerAiGateway(r, aiGateway)
|
||||
@@ -33,6 +34,13 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
||||
registerSettings(secured, settings, notifier, systemLogs, proxies)
|
||||
registerTasksAndLogs(secured, tasks, logEvents)
|
||||
registerOci(secured, ociConfigs)
|
||||
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
|
||||
// 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413
|
||||
uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||
registerOciUploads(uploads, ociConfigs)
|
||||
// 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行
|
||||
contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||
registerOciContentUploads(contentUploads, ociConfigs)
|
||||
registerAiAdmin(secured, aiGateway)
|
||||
registerSwagger(r)
|
||||
|
||||
|
||||
+118
-1
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -54,7 +56,22 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
|
||||
return oci.SubscriptionDetail{}, nil
|
||||
}
|
||||
|
||||
func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
|
||||
return "https://images.example/" + fileName, "images/generated/" + fileName, nil
|
||||
}
|
||||
|
||||
func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -88,7 +105,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 {
|
||||
@@ -183,3 +200,103 @@ func TestSystemLogsEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginFieldLimits 超长登录字段被绑定校验拒绝(S-03 高基数防护第一道)。
|
||||
func TestLoginFieldLimits(t *testing.T) {
|
||||
r, _, _ := newTestRouter(t)
|
||||
longName := strings.Repeat("a", 65)
|
||||
longPass := strings.Repeat("b", 129)
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "用户名超长", body: `{"username":"` + longName + `","password":"x"}`},
|
||||
{name: "密码超长", body: `{"username":"admin","password":"` + longPass + `"}`},
|
||||
{name: "验证码超长", body: `{"username":"admin","password":"x","totpCode":"123456789"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", tt.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLimitRejectsHuge 面板 API 请求体超 1MB 被拒(S-03)。
|
||||
func TestBodyLimitRejectsHuge(t *testing.T) {
|
||||
r, _, _ := newTestRouter(t)
|
||||
huge := `{"username":"admin","password":"` + strings.Repeat("x", 2<<20) + `"}`
|
||||
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", huge)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400(body too large)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeSessionsEndpoint 撤销全部会话:直接调用即生效,
|
||||
// 响应带新 token,旧 token 随即失效。
|
||||
func TestRevokeSessionsEndpoint(t *testing.T) {
|
||||
r, _, _ := newTestRouter(t)
|
||||
login := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pass123"}`)
|
||||
var sess struct{ Token string }
|
||||
if err := json.Unmarshal(login.Body.Bytes(), &sess); err != nil || sess.Token == "" {
|
||||
t.Fatalf("login: %s", login.Body.String())
|
||||
}
|
||||
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/revoke-sessions", sess.Token, "")
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "token") {
|
||||
t.Fatalf("revoke = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", sess.Token, "")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondErrorSanitizesInternal 内部错误边界(S-08):wrap 链细节不透传,
|
||||
// 响应为固定文案 + requestId;记录缺失映射 404 固定文案。
|
||||
func TestRespondErrorSanitizesInternal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantBody string // 必须出现
|
||||
leaks []string // 不得出现
|
||||
}{
|
||||
{
|
||||
name: "内部错误脱敏",
|
||||
err: fmt.Errorf("query users: dial tcp 10.0.0.5:3306: dsn user:secretpw"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: "requestId",
|
||||
leaks: []string{"secretpw", "10.0.0.5", "dsn", "dial tcp"},
|
||||
},
|
||||
{
|
||||
name: "记录缺失固定文案",
|
||||
err: fmt.Errorf("find oci config 5: %w", gorm.ErrRecordNotFound),
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "资源不存在",
|
||||
leaks: []string{"find oci config"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
|
||||
respondError(c, tc.err)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, tc.wantBody) {
|
||||
t.Errorf("body %q 应包含 %q", body, tc.wantBody)
|
||||
}
|
||||
for _, leak := range tc.leaks {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("body %q 泄露内部细节 %q", body, leak)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
// 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。
|
||||
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
||||
aih := &aiGatewayHandler{gw: aiGateway}
|
||||
ai := r.Group("/ai/v1", aih.auth)
|
||||
ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
|
||||
ai.POST("/chat/completions", aih.chatCompletions)
|
||||
ai.POST("/responses", aih.responses)
|
||||
ai.POST("/messages", aih.messages)
|
||||
ai.POST("/embeddings", aih.embeddings)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -26,13 +26,14 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
|
||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||
secured.GET("/auth/totp", ax.totpStatus)
|
||||
secured.POST("/auth/totp/setup", ax.totpSetup)
|
||||
secured.POST("/auth/totp/activate", ax.totpActivate)
|
||||
secured.POST("/auth/totp/disable", ax.totpDisable)
|
||||
secured.GET("/auth/credentials", ax.getCredentials)
|
||||
secured.PUT("/auth/credentials", ax.updateCredentials)
|
||||
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
|
||||
secured.GET("/auth/identities", ax.identities)
|
||||
secured.POST("/auth/totp/activate", ax.totpActivate)
|
||||
secured.POST("/auth/totp/disable", ax.totpDisable)
|
||||
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
|
||||
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
||||
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||
secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||
}
|
||||
|
||||
@@ -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,35 @@ 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.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)
|
||||
}
|
||||
|
||||
// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开
|
||||
// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。
|
||||
// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。
|
||||
func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||
h := &ociConfigHandler{svc: ociConfigs}
|
||||
g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon)
|
||||
g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon)
|
||||
}
|
||||
|
||||
// registerOciCost 成本快照。
|
||||
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.GET("/oci-configs/:id/costs", h.costs)
|
||||
@@ -109,6 +151,7 @@ func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.GET("/oci-configs/:id/audit-events", h.getAuditEvents)
|
||||
g.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
|
||||
g.GET("/oci-configs/:id/domains", h.listIdentityDomains)
|
||||
g.GET("/oci-configs/:id/users", h.listTenantUsers)
|
||||
g.POST("/oci-configs/:id/users", h.createTenantUser)
|
||||
g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
|
||||
@@ -117,6 +160,10 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
|
||||
g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
|
||||
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
||||
g.GET("/oci-configs/:id/users/:userId/api-keys", h.listTenantUserApiKeys)
|
||||
g.POST("/oci-configs/:id/users/:userId/api-keys", h.addTenantUserApiKey)
|
||||
g.DELETE("/oci-configs/:id/users/:userId/api-keys/:fingerprint", h.deleteTenantUserApiKey)
|
||||
g.POST("/oci-configs/:id/activate-api-key", h.activateApiKey)
|
||||
g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
||||
@@ -132,3 +179,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
||||
}
|
||||
|
||||
// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限);
|
||||
// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB
|
||||
// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。
|
||||
func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||
h := &ociConfigHandler{svc: ociConfigs}
|
||||
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
||||
secured.GET("/settings/telegram", st.getTelegram)
|
||||
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||
secured.POST("/settings/telegram/test", st.testTelegram)
|
||||
secured.GET("/settings/notify-channels", st.listNotifyChannels)
|
||||
secured.PUT("/settings/notify-channels/:type", st.updateNotifyChannel)
|
||||
secured.POST("/settings/notify-channels/:type/test", st.testNotifyChannel)
|
||||
secured.GET("/settings/notify-events", st.getNotifyEvents)
|
||||
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
|
||||
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
|
||||
@@ -23,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
||||
secured.GET("/settings/task", st.getTaskSettings)
|
||||
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||
secured.GET("/settings/security", st.getSecurity)
|
||||
secured.PUT("/settings/security", st.updateSecurity)
|
||||
secured.PATCH("/settings/security", st.updateSecurity)
|
||||
|
||||
px := &proxyHandler{svc: proxies}
|
||||
secured.GET("/proxies", px.list)
|
||||
@@ -32,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)
|
||||
}
|
||||
|
||||
+91
-14
@@ -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) {
|
||||
@@ -80,11 +80,87 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// listNotifyChannels 返回全部通知渠道的脱敏视图(webhook/ntfy/bark/smtp,不含 telegram)。
|
||||
//
|
||||
// @Summary 返回全部通知渠道的脱敏视图
|
||||
// @Tags 设置
|
||||
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/notify-channels [get]
|
||||
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
|
||||
items, err := h.svc.NotifyChannels(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// updateNotifyChannelRequest 是保存单渠道配置的请求体;
|
||||
// secret 承载渠道密文字段(ntfy token / bark device key / smtp 密码),
|
||||
// 缺省沿用已存值,空串清除。
|
||||
type updateNotifyChannelRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
BodyTemplate string `json:"bodyTemplate"`
|
||||
Server string `json:"server"`
|
||||
Topic string `json:"topic"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Secret *string `json:"secret"`
|
||||
}
|
||||
|
||||
// updateNotifyChannel 保存单渠道配置并返回全渠道最新视图;未知类型或校验失败返回 400。
|
||||
//
|
||||
// @Summary 保存单渠道配置并返回全渠道最新视图
|
||||
// @Tags 设置
|
||||
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
||||
// @Param body body updateNotifyChannelRequest true "请求体"
|
||||
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/notify-channels/{type} [put]
|
||||
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
|
||||
var req updateNotifyChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.svc.UpdateNotifyChannel(c.Request.Context(), c.Param("type"), service.UpdateNotifyChannelInput{
|
||||
Enabled: req.Enabled, URL: req.URL, BodyTemplate: req.BodyTemplate,
|
||||
Server: req.Server, Topic: req.Topic, Host: req.Host, Port: req.Port,
|
||||
Username: req.Username, From: req.From, To: req.To, Secret: req.Secret,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.listNotifyChannels(c)
|
||||
}
|
||||
|
||||
// testNotifyChannel 用已保存配置向指定渠道同步发送测试消息。
|
||||
//
|
||||
// @Summary 向指定渠道发送测试消息
|
||||
// @Tags 设置
|
||||
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/notify-channels/{type}/test [post]
|
||||
func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
|
||||
if err := h.notifier.SendChannelTest(c.Request.Context(), c.Param("type")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
|
||||
//
|
||||
// @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) {
|
||||
@@ -102,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) {
|
||||
@@ -122,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) {
|
||||
@@ -187,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) {
|
||||
@@ -204,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) {
|
||||
@@ -228,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) {
|
||||
@@ -240,16 +316,17 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
||||
// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
|
||||
// 越界或非法返回 400,保存后立即生效。
|
||||
//
|
||||
// @Summary 保存安全设置并返回最新值
|
||||
// @Summary 部分更新安全设置并返回最新值
|
||||
// @Tags 设置
|
||||
// @Param body body service.SecuritySettings true "请求体"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
|
||||
// @Success 200 {object} service.SecuritySettings
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/security [put]
|
||||
// @Router /api/v1/settings/security [patch]
|
||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||
var req service.SecuritySettings
|
||||
var req service.SecurityPatch
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// userApiKeysResponse 是用户 API 签名 key 列表。
|
||||
type userApiKeysResponse struct {
|
||||
Items []service.UserApiKeyInfo `json:"items"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+32
-12
@@ -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) {
|
||||
@@ -90,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
|
||||
// @Summary 更新任务
|
||||
// @Tags 任务与日志回传
|
||||
// @Param id path int true "任务 ID"
|
||||
// @Param body body updateTaskRequest true "可更新字段"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
|
||||
// @Success 200 {object} model.Task
|
||||
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
|
||||
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/tasks/{id} [put]
|
||||
func (h *taskHandler) update(c *gin.Context) {
|
||||
@@ -111,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
respondUpdateTaskErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||
func respondUpdateTaskErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrTaskConflict):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrSnatchTargetTooLow):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary 删除任务
|
||||
// @Tags 任务与日志回传
|
||||
// @Param id path int true "任务 ID"
|
||||
@@ -138,7 +154,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 +171,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 +183,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})
|
||||
}
|
||||
|
||||
+172
-39
@@ -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) {
|
||||
@@ -67,13 +72,17 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||
|
||||
// ---- 租户审计日志 ----
|
||||
|
||||
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24);
|
||||
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400。
|
||||
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
|
||||
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
|
||||
//
|
||||
// @Summary 实时查询租户 OCI 审计事件:hours 首查
|
||||
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||
// @Success 200 {object} service.AuditEventsView
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||
@@ -81,13 +90,10 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||
q := service.AuditQuery{
|
||||
Region: c.Query("region"), Hours: hours,
|
||||
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("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.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) {
|
||||
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -104,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) {
|
||||
@@ -143,10 +149,30 @@ type createTenantUserRequest struct {
|
||||
AddToAdminGroup bool `json:"addToAdminGroup"`
|
||||
}
|
||||
|
||||
// @Summary 租户身份域列表
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {array} oci.IdentityDomain
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/domains [get]
|
||||
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
domains, err := h.svc.IdentityDomains(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, domains)
|
||||
}
|
||||
|
||||
// @Summary 租户 IAM 用户列表
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {array} oci.TenantUser
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/users [get]
|
||||
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||
@@ -154,7 +180,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := h.svc.TenantUsers(c.Request.Context(), id)
|
||||
users, err := h.svc.TenantUsers(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -165,8 +191,9 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||
// @Summary 租户用户详情
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -174,7 +201,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Param("userId"))
|
||||
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -185,8 +212,9 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||
// @Summary 创建租户 IAM 用户
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -199,7 +227,7 @@ func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, oci.CreateTenantUserInput{
|
||||
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, c.Query("domainId"), oci.CreateTenantUserInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Email: req.Email,
|
||||
@@ -229,9 +257,10 @@ type updateTenantUserRequest struct {
|
||||
// @Summary 更新租户 IAM 用户
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @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) {
|
||||
@@ -244,7 +273,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Param("userId"), oci.UpdateTenantUserInput{
|
||||
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"), oci.UpdateTenantUserInput{
|
||||
Description: req.Description,
|
||||
Email: req.Email,
|
||||
GivenName: req.GivenName,
|
||||
@@ -262,6 +291,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||
// @Summary 删除租户 IAM 用户
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param userId path string true "userId"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
@@ -271,7 +301,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Param("userId")); err != nil {
|
||||
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId")); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -281,8 +311,9 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
||||
// @Summary 重置租户用户密码
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -290,7 +321,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Param("userId"))
|
||||
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -301,8 +332,9 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||
// @Summary 清除用户 MFA 设备
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -310,7 +342,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Param("userId"))
|
||||
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -322,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) {
|
||||
@@ -339,12 +371,108 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
||||
}
|
||||
|
||||
// @Summary 列出用户 API Key
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param userId path string true "userId"
|
||||
// @Success 200 {object} userApiKeysResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [get]
|
||||
func (h *ociConfigHandler) listTenantUserApiKeys(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keys, err := h.svc.TenantUserApiKeys(c.Request.Context(), id, c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": keys})
|
||||
}
|
||||
|
||||
// @Summary 为用户新增 API Key
|
||||
// @Description 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param userId path string true "userId"
|
||||
// @Success 200 {object} service.CreatedApiKey
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [post]
|
||||
func (h *ociConfigHandler) addTenantUserApiKey(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := h.svc.AddTenantUserApiKey(c.Request.Context(), id, c.Param("userId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, created)
|
||||
}
|
||||
|
||||
// @Summary 删除用户单把 API Key
|
||||
// @Description 当前配置正在使用的 key 拒删(409),避免面板失联。
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param userId path string true "userId"
|
||||
// @Param fingerprint path string true "key 指纹"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint} [delete]
|
||||
func (h *ociConfigHandler) deleteTenantUserApiKey(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := h.svc.DeleteTenantUserApiKey(c.Request.Context(), id, c.Param("userId"), c.Param("fingerprint"))
|
||||
if errors.Is(err, service.ErrCurrentApiKey) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary 启用 API Key 为面板签名凭据
|
||||
// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param body body object true "请求体:fingerprint 与 privateKey"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/activate-api-key [post]
|
||||
func (h *ociConfigHandler) activateApiKey(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Fingerprint == "" || req.PrivateKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint 与 privateKey 必填"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.Fingerprint, req.PrivateKey); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- 域通知收件人与密码策略 ----
|
||||
|
||||
// @Summary ---- 域通知收件人与密码策略 ----
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {object} oci.NotificationRecipients
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
||||
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||
@@ -352,7 +480,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id)
|
||||
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -363,8 +491,9 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||
// @Summary 更新通知收件人
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -379,7 +508,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, req.Recipients)
|
||||
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, c.Query("domainId"), req.Recipients)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -390,7 +519,8 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||
// @Summary 密码策略列表
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {array} oci.PasswordPolicyInfo
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
||||
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||
@@ -398,7 +528,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id)
|
||||
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -409,9 +539,10 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||
// @Summary 更新密码策略
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @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) {
|
||||
@@ -428,7 +559,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Param("policyId"), oci.UpdatePasswordPolicyInput{
|
||||
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Query("domainId"), c.Param("policyId"), oci.UpdatePasswordPolicyInput{
|
||||
PasswordExpiresAfter: req.PasswordExpiresAfter,
|
||||
MinLength: req.MinLength,
|
||||
NumPasswordsInHistory: req.NumPasswordsInHistory,
|
||||
@@ -443,7 +574,8 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||
// @Summary 身份域设置
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Success 200 {object} oci.IdentitySettingInfo
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
||||
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||
@@ -451,7 +583,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setting, err := h.svc.IdentitySetting(c.Request.Context(), id)
|
||||
setting, err := h.svc.IdentitySetting(c.Request.Context(), id, c.Query("domainId"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -462,8 +594,9 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||
// @Summary 更新身份域设置
|
||||
// @Tags 租户 IAM
|
||||
// @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) {
|
||||
@@ -478,7 +611,7 @@ func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, *req.PrimaryEmailRequired)
|
||||
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, c.Query("domainId"), *req.PrimaryEmailRequired)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
|
||||
@@ -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) {
|
||||
@@ -79,7 +79,7 @@ var wsUpgrader = websocket.Upgrader{
|
||||
// @Success 101 "升级为 WebSocket"
|
||||
// @Router /api/v1/console-sessions/{sessionId}/ws [get]
|
||||
func (h *consoleHandler) ws(c *gin.Context) {
|
||||
if _, err := h.auth.ParseToken(c.Query("token")); err != nil {
|
||||
if _, err := h.auth.ParseToken(c.Request.Context(), c.Query("token")); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,6 +2,9 @@ package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -12,6 +15,18 @@ import (
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// newDBLogger 构造脱敏的 GORM 日志器(S-08):SQL 一律参数化输出,
|
||||
// 实参(密码哈希 / token / 任务 payload)不落日志;业务正常路径的
|
||||
// record-not-found 不打 SQL。
|
||||
func newDBLogger() logger.Interface {
|
||||
return logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
ParameterizedQueries: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Open 按驱动打开数据库并自动迁移全部模型。
|
||||
// driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。
|
||||
func Open(driver, dsn, path string) (*gorm.DB, error) {
|
||||
@@ -20,7 +35,7 @@ func Open(driver, dsn, path string) (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
Logger: newDBLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err)
|
||||
@@ -53,7 +68,7 @@ func autoMigrate(db *gorm.DB) error {
|
||||
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
||||
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
|
||||
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
|
||||
&model.AiContentLog{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ const (
|
||||
AccountTypeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// 测活状态取值。
|
||||
// 测活状态取值;suspended 来自账户能力接口的云端暂停标记(区别于失联)。
|
||||
const (
|
||||
AliveStatusAlive = "alive"
|
||||
AliveStatusDead = "dead"
|
||||
AliveStatusSuspended = "suspended"
|
||||
AliveStatusUnknown = "unknown"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,9 @@ type User struct {
|
||||
PasswordHash string `json:"-"`
|
||||
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
|
||||
TotpSecretEnc string `json:"-"`
|
||||
// TokenVersion 是 JWT 版本号:凭据/TOTP/外部身份/登录策略变更或撤销会话时
|
||||
// 原子递增,旧版本令牌随即失效(存量令牌无 ver 视为 0,与零值兼容)
|
||||
TokenVersion uint `json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -225,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"`
|
||||
}
|
||||
@@ -237,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 = 永关(默认,红线),
|
||||
@@ -262,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"`
|
||||
@@ -282,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"`
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
@@ -19,6 +26,89 @@ type AccountProfile struct {
|
||||
PromotionExpires *time.Time
|
||||
}
|
||||
|
||||
// AccountCapabilities 是账户能力接口的关键字段。来源为 Limits 服务
|
||||
// 20230301 版 `/compartments/{tenancy}/capabilities?accountInfoOnly=true`
|
||||
// (与控制台同源;oci-go-sdk v65 未收录,自签名裸调)。
|
||||
// 该接口对暂停/已终止租户仍可访问,借此把「暂停」从「失联」中区分出来。
|
||||
type AccountCapabilities struct {
|
||||
Suspended bool `json:"suspended"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
FreeTierEnabled bool `json:"freeTierEnabled"`
|
||||
FreeTierOnly bool `json:"freeTierOnly"`
|
||||
PromotionStatus string `json:"promotionStatus"`
|
||||
ProgramType string `json:"programType"`
|
||||
HasSaasSubscription bool `json:"hasSaasSubscription"`
|
||||
Deletable bool `json:"deletable"`
|
||||
}
|
||||
|
||||
// capabilitiesEnvelope 对应接口原始响应:顶层与 accountInfo 各有一个
|
||||
// suspended,任一为 true 即视为暂停。
|
||||
type capabilitiesEnvelope struct {
|
||||
Capabilities struct {
|
||||
Suspended bool `json:"suspended"`
|
||||
AccountInfo struct {
|
||||
FreeTierEnabled bool `json:"freeTierEnabled"`
|
||||
FreeTierOnly bool `json:"freeTierOnly"`
|
||||
PromotionStatus string `json:"promotionStatus"`
|
||||
Suspended bool `json:"suspended"`
|
||||
ProgramType string `json:"programType"`
|
||||
HasSaasSubscription bool `json:"hasSaasSubscription"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
Deletable bool `json:"deletable"`
|
||||
} `json:"accountInfo"`
|
||||
} `json:"capabilities"`
|
||||
}
|
||||
|
||||
// parseAccountCapabilities 把原始响应体压平为关键字段视图。
|
||||
func parseAccountCapabilities(body []byte) (AccountCapabilities, error) {
|
||||
var env capabilitiesEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("decode account capabilities: %w", err)
|
||||
}
|
||||
info := env.Capabilities.AccountInfo
|
||||
return AccountCapabilities{
|
||||
Suspended: env.Capabilities.Suspended || info.Suspended,
|
||||
AccountStatus: info.AccountStatus,
|
||||
FreeTierEnabled: info.FreeTierEnabled,
|
||||
FreeTierOnly: info.FreeTierOnly,
|
||||
PromotionStatus: info.PromotionStatus,
|
||||
ProgramType: info.ProgramType,
|
||||
HasSaasSubscription: info.HasSaasSubscription,
|
||||
Deletable: info.Deletable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountCapabilities 实现 Client:查询账户能力(暂停/账户状态/免费层)。
|
||||
// region 应传 home region;请求带 API Key 签名,并沿用租户出站代理。
|
||||
func (c *RealClient) GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error) {
|
||||
url := fmt.Sprintf("https://limits.%s.oci.oraclecloud.com/20230301/compartments/%s/capabilities?accountInfoOnly=true",
|
||||
normalizeRegion(region), cred.TenancyOCID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("new capabilities request: %w", err)
|
||||
}
|
||||
if err := common.DefaultRequestSigner(provider(cred)).Sign(req); err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("sign capabilities request: %w", err)
|
||||
}
|
||||
hc := proxyHTTPClient(cred.Proxy)
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: proxyClientTimeout}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("get account capabilities: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("read account capabilities: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return AccountCapabilities{}, fmt.Errorf("get account capabilities: status %d", resp.StatusCode)
|
||||
}
|
||||
return parseAccountCapabilities(body)
|
||||
}
|
||||
|
||||
// ClassifyAccount 按订阅字段判定账户类别:
|
||||
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
||||
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
||||
|
||||
@@ -36,3 +36,53 @@ func TestClassifyAccount(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAccountCapabilities 用控制台同源接口的真实响应样例驱动。
|
||||
func TestParseAccountCapabilities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSusp bool
|
||||
wantStatus string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "已终止租户(顶层与 accountInfo 双 suspended)",
|
||||
body: `{"compartmentId":"ocid1.tenancy.oc1..x","capabilities":{"suspended":true,` +
|
||||
`"accountInfo":{"freeTierEnabled":false,"freeTierOnly":false,"promotionStatus":"none",` +
|
||||
`"suspended":true,"programType":"default","limitsProvisioned":true,"intentToPay":false,` +
|
||||
`"hasSaasSubscription":true,"accountStatus":"terminated","accountFlags":0,"deletable":true,` +
|
||||
`"limitIncrease":false,"orgProperties":"0"}}}`,
|
||||
wantSusp: true,
|
||||
wantStatus: "terminated",
|
||||
},
|
||||
{
|
||||
name: "正常租户",
|
||||
body: `{"capabilities":{"suspended":false,"accountInfo":{"accountStatus":"active","freeTierEnabled":true}}}`,
|
||||
wantSusp: false, wantStatus: "active",
|
||||
},
|
||||
{
|
||||
name: "仅 accountInfo 标记 suspended 也算暂停",
|
||||
body: `{"capabilities":{"suspended":false,"accountInfo":{"suspended":true}}}`,
|
||||
wantSusp: true,
|
||||
},
|
||||
{name: "非 JSON 报错", body: `<html>`, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAccountCapabilities([]byte(tc.body))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Suspended != tc.wantSusp || got.AccountStatus != tc.wantStatus {
|
||||
t.Fatalf("got %+v, want suspended=%v status=%q", got, tc.wantSusp, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+472
-75
@@ -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,89 +383,34 @@ 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
|
||||
// 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: &cred.TenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
||||
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
||||
CompartmentId: &tenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||
EndTime: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
|
||||
}
|
||||
if page != "" {
|
||||
req.Page = &page
|
||||
if cur.Page != "" {
|
||||
req.Page = &cur.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)
|
||||
return nil, "", 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) {
|
||||
for _, ev := range items {
|
||||
items := make([]AuditEvent, 0, len(resp.Items))
|
||||
for _, ev := range resp.Items {
|
||||
out := toAuditEvent(ev)
|
||||
if !keepAuditEvent(out) {
|
||||
continue
|
||||
}
|
||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||
out.Raw = raw
|
||||
}
|
||||
result.Items = append(result.Items, out)
|
||||
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 {
|
||||
@@ -163,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 {
|
||||
|
||||
+338
-26
@@ -1,14 +1,262 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||
)
|
||||
|
||||
// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。
|
||||
var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`)
|
||||
|
||||
func TestIsSearchQuotaZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"配额为零真实报错", quotaZeroErr, true},
|
||||
{"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false},
|
||||
{"其他错误", errors.New("service unavailable"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isSearchQuotaZero(tc.err); got != tc.want {
|
||||
t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchFallback(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
searchCalls, auditCalls := 0, 0
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
searchCalls++
|
||||
return nil, "", quotaZeroErr
|
||||
},
|
||||
audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
auditCalls++
|
||||
if cur.M != auditModeFallback || cur.Page != "" {
|
||||
t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur)
|
||||
}
|
||||
ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et}
|
||||
return []AuditEvent{ev}, "", nil
|
||||
},
|
||||
}
|
||||
res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("配额为零应回退成功, got %v", err)
|
||||
}
|
||||
if searchCalls != 1 {
|
||||
t.Fatalf("Search 只应试错一次, got %d", searchCalls)
|
||||
}
|
||||
if len(res.Items) < 3 || auditCalls < 3 {
|
||||
t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls)
|
||||
}
|
||||
if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours {
|
||||
t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("回退模式游标不应再调用 Search 通道")
|
||||
return nil, "", nil
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil
|
||||
},
|
||||
}
|
||||
cur := NewAuditCursor(time.Now()).toFallback()
|
||||
if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil {
|
||||
t.Fatalf("回退模式续查失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchSearchErrorNoFallback(t *testing.T) {
|
||||
f := auditFetchers{
|
||||
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
return nil, "", errors.New("search audit logs: timeout")
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("普通错误不应触发回退")
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil {
|
||||
t.Fatal("普通错误应原样上抛")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAuditTerm(t *testing.T) {
|
||||
login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"}
|
||||
noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"}
|
||||
items := []AuditEvent{login, noise}
|
||||
cases := []struct {
|
||||
name string
|
||||
cur AuditCursor
|
||||
want int
|
||||
}{
|
||||
{"无关键字原样放行", AuditCursor{}, 2},
|
||||
{"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1},
|
||||
{"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want {
|
||||
t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditBatchSearchTermPrecision(t *testing.T) {
|
||||
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||
// 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login)
|
||||
f := auditFetchers{
|
||||
search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||
return []AuditEvent{
|
||||
{EventId: "hit", EventName: "InteractiveLogin", EventTime: &et},
|
||||
{EventId: "noise1", EventName: "ListRecommendations", EventTime: &et},
|
||||
{EventId: "noise2", EventName: "SearchLogs", EventTime: &et},
|
||||
}, "", nil
|
||||
},
|
||||
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||
t.Fatal("Search 正常时不应走回退")
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
cur := NewAuditCursor(time.Now())
|
||||
cur.Q = "login"
|
||||
res, err := listAuditBatch(context.Background(), f, cur, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("listAuditBatch() err = %v", err)
|
||||
}
|
||||
if len(res.Items) != 1 || res.Items[0].EventId != "hit" {
|
||||
t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesAuditTerm(t *testing.T) {
|
||||
ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"}
|
||||
cases := []struct {
|
||||
name string
|
||||
q string
|
||||
want bool
|
||||
}{
|
||||
{"不区分大小写", "listvnic", true},
|
||||
{"通配分段都出现", "List*Attachments", true},
|
||||
{"资源名命中", "WEB-1", true},
|
||||
{"未命中", "TerminateInstance", false},
|
||||
{"通配缺段不命中", "List*Volume", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := matchesAuditTerm(ev, tc.q); got != tc.want {
|
||||
t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。
|
||||
func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult {
|
||||
t.Helper()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("fixture 不是合法 JSON: %v", err)
|
||||
}
|
||||
return loggingsearch.SearchResult{Data: &v}
|
||||
}
|
||||
|
||||
func TestToSearchAuditEvent(t *testing.T) {
|
||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantOK bool
|
||||
want AuditEvent
|
||||
}{
|
||||
{
|
||||
name: "全字段齐全",
|
||||
data: `{"datetime":1783074600000,"logContent":{
|
||||
"id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi",
|
||||
"data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod",
|
||||
"identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"},
|
||||
"request":{"action":"DELETE","path":"/20160918/instances/ocid1..."},
|
||||
"response":{"status":"204"}}}}`,
|
||||
wantOK: true,
|
||||
want: AuditEvent{
|
||||
EventId: "evt-abc",
|
||||
EventTime: &eventTime,
|
||||
EventName: "TerminateInstance",
|
||||
Source: "ComputeApi",
|
||||
ResourceName: "web-1",
|
||||
CompartmentName: "prod",
|
||||
PrincipalName: "api-admin",
|
||||
IPAddress: "1.2.3.4",
|
||||
Status: "204",
|
||||
RequestAction: "DELETE",
|
||||
RequestPath: "/20160918/instances/ocid1...",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity/request/response 为 null 时只保留信封字段",
|
||||
data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi",
|
||||
"data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`,
|
||||
wantOK: true,
|
||||
want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"},
|
||||
},
|
||||
{
|
||||
name: "缺 logContent 丢弃",
|
||||
data: `{"datetime":1783074600000}`,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "logContent 结构不符丢弃",
|
||||
data: `{"logContent":"plain-text"}`,
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data))
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(got.Raw) == 0 {
|
||||
t.Fatalf("Raw 应携带 logContent 原文")
|
||||
}
|
||||
if !auditEventEqual(got, tt.want) {
|
||||
t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSearchAuditEventNilData(t *testing.T) {
|
||||
if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok {
|
||||
t.Fatal("Data 为 nil 应丢弃")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToAuditEvent(t *testing.T) {
|
||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: AuditEvent{
|
||||
EventId: "evt-abc",
|
||||
EventTime: &eventTime,
|
||||
EventName: "TerminateInstance",
|
||||
Source: "ComputeApi",
|
||||
ResourceName: "web-1",
|
||||
CompartmentName: "prod",
|
||||
PrincipalName: "api-admin",
|
||||
IPAddress: "1.2.3.4",
|
||||
Status: "204",
|
||||
RequestAction: "DELETE",
|
||||
RequestPath: "/20160918/instances/ocid1...",
|
||||
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
|
||||
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
|
||||
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
|
||||
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Data 为 nil 时只保留信封字段",
|
||||
ev: audit.AuditEvent{
|
||||
Source: common.String("VcnApi"),
|
||||
EventTime: &common.SDKTime{Time: eventTime},
|
||||
},
|
||||
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
||||
},
|
||||
{
|
||||
name: "嵌套局部 nil 各自安全跳过",
|
||||
ev: audit.AuditEvent{
|
||||
Data: &audit.Data{
|
||||
EventName: common.String("GetInstance"),
|
||||
Identity: nil,
|
||||
Request: &audit.Request{Path: common.String("/instances")},
|
||||
Response: nil,
|
||||
},
|
||||
},
|
||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||
},
|
||||
{
|
||||
name: "空事件全部零值",
|
||||
ev: audit.AuditEvent{},
|
||||
want: AuditEvent{},
|
||||
},
|
||||
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditSearchQuery(t *testing.T) {
|
||||
const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'`
|
||||
cases := []struct {
|
||||
name string
|
||||
term string
|
||||
want string
|
||||
}{
|
||||
{"无关键字", "", prefix + ` | sort by datetime desc`},
|
||||
{"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`},
|
||||
{"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`},
|
||||
{"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want {
|
||||
t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditTerm(t *testing.T) {
|
||||
if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" {
|
||||
t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got)
|
||||
}
|
||||
long := strings.Repeat("a", 300)
|
||||
if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen {
|
||||
t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||
func auditEventEqual(a, b AuditEvent) bool {
|
||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||
@@ -142,3 +400,57 @@ func TestKeepAuditEvent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditCursorAdvance(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
base := AuditCursor{
|
||||
Start: now.Add(-24 * time.Hour),
|
||||
End: now,
|
||||
WindowHours: 24,
|
||||
Q: "kw",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cur AuditCursor
|
||||
empty bool
|
||||
wantHours int
|
||||
wantDone bool
|
||||
}{
|
||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||
{"空窗倍增", base, true, 48, 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 {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next, done := tc.cur.advance(now, tc.empty)
|
||||
if done != tc.wantDone {
|
||||
t.Fatalf("done = %v, want %v", done, tc.wantDone)
|
||||
}
|
||||
if done {
|
||||
return
|
||||
}
|
||||
if next.WindowHours != tc.wantHours {
|
||||
t.Fatalf("WindowHours = %d, want %d", next.WindowHours, tc.wantHours)
|
||||
}
|
||||
if !next.End.Equal(tc.cur.Start) {
|
||||
t.Fatalf("新窗 End = %v, 应紧邻上窗 Start %v", next.End, tc.cur.Start)
|
||||
}
|
||||
if got := next.End.Sub(next.Start); got != time.Duration(tc.wantHours)*time.Hour {
|
||||
t.Fatalf("窗宽 = %v, want %dh", got, tc.wantHours)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ®ion,
|
||||
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: ®ion,
|
||||
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: ®ion,
|
||||
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: ®ion,
|
||||
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: ®ion,
|
||||
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
|
||||
}
|
||||
+19
-2
@@ -32,17 +32,27 @@ 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 写操作成功后失效该租户全部读缓存。
|
||||
func (c *CachedClient) bust(err error, cred Credentials) {
|
||||
if err == nil {
|
||||
c.store.DeletePrefix(cred.TenancyOCID + "|")
|
||||
c.InvalidateTenancy(cred.TenancyOCID)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateTenancy 主动失效指定 tenancy 的全部 OCI 读缓存。
|
||||
func (c *CachedClient) InvalidateTenancy(tenancyOCID string) {
|
||||
if tenancyOCID == "" {
|
||||
return
|
||||
}
|
||||
c.store.DeletePrefix(tenancyOCID + "|")
|
||||
}
|
||||
|
||||
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
||||
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
|
||||
v, err := cache.Do(c.store, key, ttl, fn)
|
||||
@@ -120,6 +130,13 @@ func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentia
|
||||
})
|
||||
}
|
||||
|
||||
// ListIdentityDomains 域列表准静态且是所有按域操作的 URL 解析源,长 TTL 缓存。
|
||||
func (c *CachedClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
|
||||
return cachedList(c, ckey(cred, "iddomains", region), cacheTTLStatic, func() ([]IdentityDomain, error) {
|
||||
return c.Client.ListIdentityDomains(ctx, cred, region)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 写:直通,成功后按租户失效 ----
|
||||
|
||||
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -86,3 +93,18 @@ func TestCachedClientReturnsClone(t *testing.T) {
|
||||
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedClientInvalidateTenancy(t *testing.T) {
|
||||
inner := &countingClient{}
|
||||
c := NewCachedClient(inner)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
c.InvalidateTenancy("t1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
if inner.instCalls != 3 {
|
||||
t.Fatalf("invalidate tenancy calls = %d, want 3", inner.instCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+91
-24
@@ -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,36 @@ 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
|
||||
// AbortAllMultipartUploads 中止桶内全部未完成分片上传(清空删桶前置步骤,404 幂等)
|
||||
AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket 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 +137,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)
|
||||
@@ -122,32 +170,49 @@ type Client interface {
|
||||
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
|
||||
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
||||
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
||||
// 批式懒加载走 ListAuditEventsBatch,本方法保留给详情小窗反查。
|
||||
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
||||
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
|
||||
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
|
||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
|
||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
|
||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
|
||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
|
||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error)
|
||||
// ListAuditEventsBatch 从游标位置向更早方向收集约 limit 条保留事件,
|
||||
// 分窗回溯(空窗倍增),跨批不重不漏;到 365 天保留期尽头置 Exhausted。
|
||||
ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error)
|
||||
// 身份域:租户 ACTIVE 域枚举(多域租户选择器数据源)。
|
||||
ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error)
|
||||
// 账户能力:暂停标记/账户状态/免费层信息(对暂停租户仍可访问,测活据此判「暂停」)。
|
||||
GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error)
|
||||
// 租户用户管理:domainID 非空按域走 Identity Domains SCIM,
|
||||
// 为空保持经典 IAM 为主、新式租户自动回退的旧行为。
|
||||
ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error)
|
||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error)
|
||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error)
|
||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error
|
||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
|
||||
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||
// 用户 API 签名 key 管理:列出(标注当前使用)、上传公钥(返回指纹)、按指纹删单把。
|
||||
ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error)
|
||||
UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error)
|
||||
DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error
|
||||
// 域设置:通知收件人、密码策略、身份设置。
|
||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
|
||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
|
||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
|
||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
|
||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
|
||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error)
|
||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||
GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error)
|
||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
||||
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
|
||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
|
||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
|
||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
|
||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
|
||||
ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error)
|
||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
|
||||
// UploadDomainImage 上传公开图片到身份域存储(IdP 图标用),返回公网 URL 与存储内文件名。
|
||||
UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error)
|
||||
// DeleteDomainImage 按存储内文件名删除身份域公开图片。
|
||||
DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error
|
||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
||||
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
||||
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
||||
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
||||
@@ -166,6 +231,8 @@ type Client interface {
|
||||
type RealClient struct {
|
||||
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
|
||||
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
|
||||
}
|
||||
|
||||
// NewClient 返回生产使用的真实客户端。
|
||||
|
||||
+38
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。
|
||||
const domainImagesPath = "/storage/v1/Images"
|
||||
|
||||
// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl
|
||||
// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient
|
||||
// 发裸 multipart 请求,OCI 签名与代理配置直接复用。
|
||||
func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||
return parseImageUploadResponse(resp, callErr)
|
||||
}
|
||||
|
||||
// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。
|
||||
func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||
return finishDomainImageDelete(resp, callErr)
|
||||
}
|
||||
|
||||
func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) {
|
||||
body, contentType, err := imageMultipart(fileName, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image upload request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) {
|
||||
u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image delete URL: %w", err)
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("fileName", fileName)
|
||||
u.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image delete request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。
|
||||
func imageMultipart(fileName string, data []byte) ([]byte, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if err := w.WriteField("fileName", fileName); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。
|
||||
func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer drainAndClose(resp.Body)
|
||||
}
|
||||
if callErr != nil {
|
||||
return "", "", fmt.Errorf("upload domain image: %w", callErr)
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return "", "", fmt.Errorf("upload domain image: empty response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read image upload response: %w", err)
|
||||
}
|
||||
if len(b) > 1<<20 {
|
||||
return "", "", fmt.Errorf("upload domain image: response exceeds 1MB")
|
||||
}
|
||||
return decodeImageUploadResponse(b)
|
||||
}
|
||||
|
||||
func decodeImageUploadResponse(data []byte) (string, string, error) {
|
||||
var out struct {
|
||||
FileURL string `json:"fileUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", "", fmt.Errorf("parse image upload response: %w", err)
|
||||
}
|
||||
if out.FileURL == "" || out.FileName == "" {
|
||||
return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName")
|
||||
}
|
||||
return out.FileURL, out.FileName, nil
|
||||
}
|
||||
|
||||
func finishDomainImageDelete(resp *http.Response, callErr error) error {
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer drainAndClose(resp.Body)
|
||||
}
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
return fmt.Errorf("delete domain image: %w", callErr)
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("delete domain image: empty response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
|
||||
_ = body.Close()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type trackingReadCloser struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
type testMultipartFile struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) {
|
||||
reader := &trackingReadCloser{Reader: strings.NewReader(body)}
|
||||
return &http.Response{StatusCode: status, Body: reader}, reader
|
||||
}
|
||||
|
||||
func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) {
|
||||
t.Helper()
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content type: %v", err)
|
||||
}
|
||||
reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
|
||||
fields, files := map[string]string{}, map[string]testMultipartFile{}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("next part: %v", err)
|
||||
}
|
||||
data, _ := io.ReadAll(part)
|
||||
if part.FileName() == "" {
|
||||
fields[part.FormName()] = string(data)
|
||||
} else {
|
||||
files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data}
|
||||
}
|
||||
}
|
||||
return fields, files
|
||||
}
|
||||
|
||||
func TestImageMultipartIncludesFileAndName(t *testing.T) {
|
||||
fileName := "idp-icon-00112233445566778899aabbccddeeff.png"
|
||||
body, contentType, err := imageMultipart(fileName, []byte("image-data"))
|
||||
if err != nil {
|
||||
t.Fatalf("imageMultipart: %v", err)
|
||||
}
|
||||
fields, files := readMultipartFields(t, body, contentType)
|
||||
if got := fields["fileName"]; got != fileName {
|
||||
t.Errorf("fileName = %q, want %q", got, fileName)
|
||||
}
|
||||
file := files["file"]
|
||||
if file.name != fileName || string(file.data) != "image-data" {
|
||||
t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageUploadResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, body, wantURL, wantName string
|
||||
status int
|
||||
callErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false},
|
||||
{"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true},
|
||||
{"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true},
|
||||
{"bad json", `{`, "", "", 200, nil, true},
|
||||
{"http error hides body", `secret-body`, "", "", 400, nil, true},
|
||||
{"call error", `{}`, "", "", 500, errors.New("upstream"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, body := testImageResponse(tc.status, tc.body)
|
||||
gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr)
|
||||
if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr {
|
||||
t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr)
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("response body was not closed")
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "secret-body") {
|
||||
t.Errorf("error leaked response body: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) {
|
||||
fileName := "images/folder/a b+%.png"
|
||||
req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("newDomainImageDeleteRequest: %v", err)
|
||||
}
|
||||
if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath {
|
||||
t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath)
|
||||
}
|
||||
if got := req.URL.Query().Get("fileName"); got != fileName {
|
||||
t.Errorf("decoded fileName = %q, want %q", got, fileName)
|
||||
}
|
||||
if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") {
|
||||
t.Errorf("raw query is not encoded: %q", req.URL.RawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishDomainImageDelete(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
callErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{"deleted", http.StatusNoContent, nil, false},
|
||||
{"already gone", http.StatusNotFound, errors.New("not found"), false},
|
||||
{"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, body := testImageResponse(tc.status, "response")
|
||||
err := finishDomainImageDelete(resp, tc.callErr)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("response body was not closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,26 +47,82 @@ type IdentitySettingInfo struct {
|
||||
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||||
}
|
||||
|
||||
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
|
||||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
// IdentityDomain 是租户下一个 ACTIVE 身份域的列表视图;
|
||||
// URL 仅供后端解析 SCIM 端点,不下发给前端(防面板被引导签名任意地址)。
|
||||
type IdentityDomain struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
URL string `json:"-"`
|
||||
HomeRegion string `json:"homeRegion"`
|
||||
Type string `json:"type"`
|
||||
LicenseType string `json:"licenseType"`
|
||||
}
|
||||
|
||||
// ListIdentityDomains 实现 Client:列出租户全部 ACTIVE 身份域。
|
||||
// 无域老租户返回空列表(非错误),调用方据此回退经典 IAM 路径。
|
||||
func (c *RealClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
|
||||
items, err := c.listDomainSummaries(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]IdentityDomain, 0, len(items))
|
||||
for _, d := range items {
|
||||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, IdentityDomain{
|
||||
ID: deref(d.Id),
|
||||
DisplayName: deref(d.DisplayName),
|
||||
URL: deref(d.Url),
|
||||
HomeRegion: deref(d.HomeRegion),
|
||||
Type: string(d.Type),
|
||||
LicenseType: deref(d.LicenseType),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listDomainSummaries 拉取租户全部域(数量极少,仍按游标翻全以防万一)。
|
||||
func (c *RealClient) listDomainSummaries(ctx context.Context, cred Credentials, region string) ([]identity.DomainSummary, error) {
|
||||
ic, err := c.identityClientAt(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []identity.DomainSummary
|
||||
var page *string
|
||||
for {
|
||||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID, Page: page})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identity domains: %w", err)
|
||||
}
|
||||
items = append(items, resp.Items...)
|
||||
if resp.OpcNextPage == nil {
|
||||
return items, nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// domainURL 解析身份域的 endpoint URL:domainID 非空按 OCID 精确匹配
|
||||
// (URL 只能来自租户自己的域列表,不信任外部输入),为空回退 Default 优先。
|
||||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region, domainID string) (string, error) {
|
||||
items, err := c.listDomainSummaries(ctx, cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list identity domains: %w", err)
|
||||
}
|
||||
url := pickDomainURL(resp.Items)
|
||||
url := pickDomainURL(items, domainID)
|
||||
if url == "" {
|
||||
if domainID != "" {
|
||||
return "", fmt.Errorf("identity domain %s not found or inactive", domainID)
|
||||
}
|
||||
return "", fmt.Errorf("no active identity domain found")
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
|
||||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
// domainsClient 解析身份域(domainID 为空取 Default)并构造 SCIM 客户端。
|
||||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region, domainID string) (identitydomains.IdentityDomainsClient, error) {
|
||||
url, err := c.domainURL(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return identitydomains.IdentityDomainsClient{}, err
|
||||
}
|
||||
@@ -80,13 +136,20 @@ func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||
func pickDomainURL(domains []identity.DomainSummary) string {
|
||||
// pickDomainURL 在 ACTIVE 域中选取:domainID 非空按 OCID 匹配;
|
||||
// 为空优先名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||
func pickDomainURL(domains []identity.DomainSummary, domainID string) string {
|
||||
fallback := ""
|
||||
for _, d := range domains {
|
||||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||||
continue
|
||||
}
|
||||
if domainID != "" {
|
||||
if deref(d.Id) == domainID {
|
||||
return deref(d.Url)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if deref(d.DisplayName) == "Default" {
|
||||
return deref(d.Url)
|
||||
}
|
||||
@@ -98,8 +161,8 @@ func pickDomainURL(domains []identity.DomainSummary) string {
|
||||
}
|
||||
|
||||
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
@@ -115,8 +178,8 @@ func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credent
|
||||
|
||||
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||||
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
@@ -153,8 +216,8 @@ func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDoma
|
||||
}
|
||||
|
||||
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,12 +234,12 @@ func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials,
|
||||
|
||||
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||||
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
||||
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||||
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||||
ops := buildPolicyPatchOps(in)
|
||||
if len(ops) == 0 {
|
||||
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return PasswordPolicyInfo{}, err
|
||||
}
|
||||
@@ -233,8 +296,8 @@ func removeOp(path string) identitydomains.Operations {
|
||||
}
|
||||
|
||||
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
@@ -254,12 +317,12 @@ func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, r
|
||||
}
|
||||
|
||||
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||
current, err := c.GetIdentitySetting(ctx, cred, region)
|
||||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||
current, err := c.GetIdentitySetting(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+127
-34
@@ -3,6 +3,7 @@ package oci
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
|
||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||
|
||||
const idpCreateRollbackWait = 15 * time.Second
|
||||
|
||||
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
|
||||
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
|
||||
|
||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||
type IdentityProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
|
||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||
}
|
||||
|
||||
// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。
|
||||
// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。
|
||||
type PartialIdentityProviderCreateError struct {
|
||||
IdentityProvider IdentityProviderInfo
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Error() string {
|
||||
return "identity provider was created but JIT setup is incomplete"
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause }
|
||||
|
||||
type federationDomainsClient interface {
|
||||
ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error)
|
||||
CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error)
|
||||
GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error)
|
||||
PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error)
|
||||
DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error)
|
||||
}
|
||||
|
||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||
@@ -56,8 +83,8 @@ type CreateIdpInput struct {
|
||||
}
|
||||
|
||||
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
||||
return idps, nil
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT;
|
||||
// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
return createSamlIdentityProvider(ctx, dc, in)
|
||||
}
|
||||
|
||||
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||
var err error
|
||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||
}
|
||||
if in.JitEnabled {
|
||||
info := toIdpInfo(resp.IdentityProvider)
|
||||
if !in.JitEnabled {
|
||||
return info, nil
|
||||
}
|
||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||
return toIdpInfo(resp.IdentityProvider), err
|
||||
return rollbackIncompleteIdp(ctx, dc, info, err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
|
||||
if info.ID == "" {
|
||||
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
return toIdpInfo(resp.IdentityProvider), nil
|
||||
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
|
||||
defer cancel()
|
||||
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
|
||||
IdentityProviderId: &info.ID,
|
||||
})
|
||||
if rollbackErr == nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
|
||||
}
|
||||
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
|
||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||
@@ -139,22 +191,27 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
||||
return idp
|
||||
}
|
||||
|
||||
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
||||
count := 1
|
||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||||
count := len(adminGroupNames)
|
||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||
Filter: &filter,
|
||||
Attributes: common.String("id,displayName"),
|
||||
Count: &count,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find Administrators group: %w", err)
|
||||
return nil, fmt.Errorf("find administrators group: %w", err)
|
||||
}
|
||||
for _, name := range adminGroupNames {
|
||||
for _, g := range resp.Resources {
|
||||
if deref(g.DisplayName) == name && g.Id != nil {
|
||||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: g.Id}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
||||
}
|
||||
|
||||
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||
@@ -175,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
|
||||
}
|
||||
|
||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
ref := idp.JitUserProvAttributes
|
||||
if ref == nil || ref.Value == nil {
|
||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
||||
@@ -207,8 +264,8 @@ func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.Identity
|
||||
|
||||
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
@@ -249,8 +306,8 @@ func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClie
|
||||
}
|
||||
|
||||
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -297,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains
|
||||
}
|
||||
|
||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||
// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建,
|
||||
// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。
|
||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||
returns := make([]interface{}, 0, len(items))
|
||||
changed := false
|
||||
returns := make([]interface{}, 0, len(items)+1)
|
||||
changed, seen := false, false
|
||||
for _, item := range items {
|
||||
name, value := deref(item.Name), deref(item.Value)
|
||||
if name == "SamlIDPs" {
|
||||
seen = true
|
||||
next, ok, err := toggleJSONList(value, idpID, show)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||
@@ -311,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho
|
||||
}
|
||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
if !seen && show {
|
||||
returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)})
|
||||
changed = true
|
||||
}
|
||||
return returns, changed, nil
|
||||
}
|
||||
|
||||
@@ -346,22 +410,22 @@ func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
||||
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) {
|
||||
url, err := c.domainURL(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := fetchSamlMetadata(ctx, url)
|
||||
body, status, err := fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
return body, nil
|
||||
}
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
body, status, err = fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,27 +435,56 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
|
||||
// 防异常缓慢或超大响应长期占住请求与内存。
|
||||
const (
|
||||
samlMetadataTimeout = 30 * time.Second
|
||||
samlMetadataMaxBytes = 4 << 20
|
||||
)
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名,
|
||||
// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。
|
||||
func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) {
|
||||
client, err := metadataHTTPClient(cred)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||
}
|
||||
if len(body) > samlMetadataMaxBytes {
|
||||
return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes)
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时
|
||||
// 报错而非静默直连(失败关闭)。
|
||||
func metadataHTTPClient(cred Credentials) (*http.Client, error) {
|
||||
if cred.Proxy == nil {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
if hc := HTTPClientFor(cred.Proxy); hc != nil {
|
||||
return hc, nil
|
||||
}
|
||||
return nil, errors.New("download saml metadata: invalid proxy config")
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
type federationCreateStub struct {
|
||||
created identitydomains.IdentityProvider
|
||||
createErr error
|
||||
patchErr error
|
||||
deleteErr error
|
||||
patchCalls int
|
||||
deletedID string
|
||||
deleteCtxErr error
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) {
|
||||
return identitydomains.ListGroupsResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) {
|
||||
return identitydomains.CreateIdentityProviderResponse{IdentityProvider: s.created}, s.createErr
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) {
|
||||
return identitydomains.GetIdentityProviderResponse{IdentityProvider: s.created}, nil
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) {
|
||||
s.patchCalls++
|
||||
return identitydomains.PatchMappedAttributeResponse{}, s.patchErr
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) DeleteIdentityProvider(ctx context.Context, request identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) {
|
||||
s.deletedID = deref(request.IdentityProviderId)
|
||||
s.deleteCtxErr = ctx.Err()
|
||||
return identitydomains.DeleteIdentityProviderResponse{}, s.deleteErr
|
||||
}
|
||||
|
||||
func createdJitIdp(id string) identitydomains.IdentityProvider {
|
||||
return identitydomains.IdentityProvider{
|
||||
Id: idPtr(id), PartnerName: common.String("test-idp"), Type: identitydomains.IdentityProviderTypeSaml,
|
||||
JitUserProvEnabled: common.Bool(true), JitUserProvAttributes: &identitydomains.IdentityProviderJitUserProvAttributes{Value: common.String("mapping-1")},
|
||||
}
|
||||
}
|
||||
|
||||
func idPtr(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return common.String(value)
|
||||
}
|
||||
|
||||
func testJitInput() CreateIdpInput {
|
||||
return CreateIdpInput{Name: "test-idp", JitEnabled: true, IconURL: "https://img.example/icon.png"}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderCreateFailure(t *testing.T) {
|
||||
createErr := errors.New("create rejected")
|
||||
stub := &federationCreateStub{createErr: createErr}
|
||||
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||
if !errors.Is(err, createErr) {
|
||||
t.Fatalf("err = %v, want create error", err)
|
||||
}
|
||||
if got.ID != "" || stub.patchCalls != 0 || stub.deletedID != "" {
|
||||
t.Errorf("got = %+v, patchCalls = %d, deletedID = %q", got, stub.patchCalls, stub.deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderJitFailureRollbackSuccess(t *testing.T) {
|
||||
patchErr := errors.New("patch rejected")
|
||||
stub := &federationCreateStub{created: createdJitIdp("idp-1"), patchErr: patchErr}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got, err := createSamlIdentityProvider(ctx, stub, testJitInput())
|
||||
var partial *PartialIdentityProviderCreateError
|
||||
if !errors.Is(err, patchErr) || errors.As(err, &partial) {
|
||||
t.Fatalf("err = %v, want ordinary wrapped patch error", err)
|
||||
}
|
||||
if got.ID != "" || stub.deletedID != "idp-1" || stub.deleteCtxErr != nil {
|
||||
t.Errorf("got.ID = %q, deletedID = %q, deleteCtxErr = %v", got.ID, stub.deletedID, stub.deleteCtxErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderPartialCreateContract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, id string
|
||||
deleteErr error
|
||||
wantDelete bool
|
||||
}{
|
||||
{"rollback fails", "idp-1", errors.New("rollback secret detail"), true},
|
||||
{"created id missing", "", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) { assertPartialCreate(t, tc.id, tc.deleteErr, tc.wantDelete) })
|
||||
}
|
||||
}
|
||||
|
||||
func assertPartialCreate(t *testing.T, id string, deleteErr error, wantDelete bool) {
|
||||
t.Helper()
|
||||
stub := &federationCreateStub{created: createdJitIdp(id), patchErr: errors.New("patch secret detail"), deleteErr: deleteErr}
|
||||
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||
var partial *PartialIdentityProviderCreateError
|
||||
if !errors.As(err, &partial) || partial.IdentityProvider.ID != id {
|
||||
t.Fatalf("got = %+v, err = %v, partial = %+v", got, err, partial)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret") || (stub.deletedID != "") != wantDelete {
|
||||
t.Errorf("unsafe err = %q or deletedID = %q, wantDelete = %v", err, stub.deletedID, wantDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func ruleReturn(name, value string) identitydomains.RuleReturn {
|
||||
return identitydomains.RuleReturn{Name: common.String(name), Value: common.String(value)}
|
||||
}
|
||||
|
||||
type rebuildIdpReturnCase struct {
|
||||
name string
|
||||
items []identitydomains.RuleReturn
|
||||
show bool
|
||||
changed bool
|
||||
want string
|
||||
}
|
||||
|
||||
func TestRebuildSamlIdpsReturn(t *testing.T) {
|
||||
local := ruleReturn("LocalIDPs", `["UserNamePassword"]`)
|
||||
cases := []rebuildIdpReturnCase{
|
||||
{"无SamlIDPs项时添加须补建", []identitydomains.RuleReturn{local}, true, true, `["idp-1"]`},
|
||||
{"无SamlIDPs项时移除无变化", []identitydomains.RuleReturn{local}, false, false, ""},
|
||||
{"已有其他IdP时追加", []identitydomains.RuleReturn{local, ruleReturn("SamlIDPs", `["other"]`)}, true, true, `["other","idp-1"]`},
|
||||
{"已在列表中再添加无变化", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1"]`)}, true, false, `["idp-1"]`},
|
||||
{"移除目标IdP", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1","other"]`)}, false, true, `["other"]`},
|
||||
{"空值项添加", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "")}, true, true, `["idp-1"]`},
|
||||
}
|
||||
assertRebuildIdpReturns(t, cases)
|
||||
}
|
||||
|
||||
func assertRebuildIdpReturns(t *testing.T, cases []rebuildIdpReturnCase) {
|
||||
t.Helper()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
returns, changed, err := rebuildSamlIdpsReturn(tc.items, "idp-1", tc.show)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuildSamlIdpsReturn: %v", err)
|
||||
}
|
||||
if changed != tc.changed {
|
||||
t.Errorf("changed = %v, want %v", changed, tc.changed)
|
||||
}
|
||||
got := ""
|
||||
for _, r := range returns {
|
||||
m := r.(map[string]string)
|
||||
if m["name"] == "SamlIDPs" {
|
||||
got = m["value"]
|
||||
}
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("SamlIDPs = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildSamlIdpsReturnBadJSON(t *testing.T) {
|
||||
items := []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "not-json")}
|
||||
if _, _, err := rebuildSamlIdpsReturn(items, "idp-1", true); err == nil {
|
||||
t.Fatal("坏 JSON 应报错而非静默覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchSamlMetadataLimitsBody 锁定匿名元数据请求的响应体上限:超限报错而非吞下。
|
||||
func TestFetchSamlMetadataLimitsBody(t *testing.T) {
|
||||
huge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(bytes.Repeat([]byte("x"), samlMetadataMaxBytes+1))
|
||||
}))
|
||||
defer huge.Close()
|
||||
if _, _, err := fetchSamlMetadata(context.Background(), Credentials{}, huge.URL); err == nil ||
|
||||
!strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("err = %v, want 超限错误", err)
|
||||
}
|
||||
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<EntityDescriptor/>"))
|
||||
}))
|
||||
defer ok.Close()
|
||||
body, status, err := fetchSamlMetadata(context.Background(), Credentials{}, ok.URL)
|
||||
if err != nil || status != http.StatusOK || string(body) != "<EntityDescriptor/>" {
|
||||
t.Fatalf("fetch = %q, %d, %v; want 正常返回", body, status, err)
|
||||
}
|
||||
}
|
||||
+63
-129
@@ -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)
|
||||
|
||||
@@ -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), ¶ms) != nil || params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
||||
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: ¶ms}
|
||||
}
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms) == nil {
|
||||
fd.Parameters = ¶ms
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 := readCompatBody(response.Body, compatResponsesLimit, "compat responses")
|
||||
if err != nil {
|
||||
return nil, 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)
|
||||
}
|
||||
|
||||
// readCompatBody 读取上游响应体并施加上限;读 limit+1 判超报错——
|
||||
// 静默截断的 JSON/音频配 200 会被下游当完整成功记账。
|
||||
func readCompatBody(body io.Reader, limit int64, tag string) ([]byte, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s body: %w", tag, err)
|
||||
}
|
||||
if int64(len(payload)) > limit {
|
||||
return nil, fmt.Errorf("%s body exceeds %d bytes", tag, limit)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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 应已取消")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestReadCompatBodyRejectsOversize 锁定上游响应上限:恰好达限通过,超限报错,
|
||||
// 不允许静默截断配 200 让下游当完整成功。
|
||||
func TestReadCompatBodyRejectsOversize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
size int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"under limit", 15, false},
|
||||
{"exactly limit", 16, false},
|
||||
{"over limit", 17, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := readCompatBody(bytes.NewReader(make([]byte, tc.size)), 16, "test")
|
||||
if tc.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("err = %v, want 超限错误", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || int64(len(payload)) != tc.size {
|
||||
t.Fatalf("payload = %d bytes, %v; want %d", len(payload), err, tc.size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user