27 Commits
Author SHA1 Message Date
wangdefa 23b2820101 发布 v0.8.3
CI / test (push) Successful in 22s
Release / release (push) Successful in 44s
2026-07-30 12:44:14 +08:00
wangdefa 109c345e5e 新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s
2026-07-30 12:23:05 +08:00
wangdefa f1914880ac 发布 v0.8.2
CI / test (push) Successful in 21s
Release / release (push) Successful in 54s
2026-07-22 22:15:23 +08:00
wangdefa a95a8255bf 用户 API Key 管理:增删查、启用签名凭据;升级 SDK
CI / test (push) Successful in 48s
2026-07-22 19:38:14 +08:00
wangdefa 8894330eba 发布 v0.8.1
CI / test (push) Successful in 21s
Release / release (push) Successful in 42s
2026-07-22 17:10:58 +08:00
wangdefa f51fb6c722 修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s
2026-07-22 16:51:23 +08:00
wangdefa 0614ef22af 发布 v0.8.0
CI / test (push) Successful in 22s
Release / release (push) Successful in 51s
2026-07-21 19:43:02 +08:00
wangdefa 33e92a65e2 成本查询限窗过滤;桶级 PAR 放开列举
CI / test (push) Successful in 35s
2026-07-21 19:26:33 +08:00
wangdefa f40f2a20e8 新增对象存储/账单/保留IP模块,删桶并发提速与成本增强
CI / test (push) Successful in 33s
2026-07-21 12:11:05 +08:00
wangdefa 9cfde8b702 发布 v0.7.3
CI / test (push) Successful in 20s
Release / release (push) Successful in 58s
2026-07-17 17:09:06 +08:00
wangdefa deea8629e0 实例规格透传 quotaNames,重新生成 swagger
CI / test (push) Successful in 31s
2026-07-17 16:52:49 +08:00
wangdefa 6cf9465fea 云端事件:修复实例通知并移出 LaunchInstance 回传 2026-07-17 16:52:49 +08:00
wangdefa 882eeade1e 修复跨区间缓存串数据与会话回收泄漏,收敛网关重试
CI / test (push) Successful in 29s
2026-07-17 12:19:21 +08:00
wangdefa 7019d4c5a6 文档:记录 multi-agent 加密推理流式断流问题 2026-07-17 12:19:21 +08:00
wangdefa 18e63d2dbd 补提 DASH_VERSION 至 v0.7.2
CI / test (push) Successful in 21s
Release / release (push) Successful in 52s
2026-07-16 21:33:38 +08:00
wangdefa 91999205e2 发布 v0.7.2
CI / test (push) Successful in 22s
Release / release (push) Successful in 48s
2026-07-16 21:26:39 +08:00
wangdefa cb66567256 responses 直通超时可配,流式去总超时,代理补阶段超时
CI / test (push) Successful in 31s
2026-07-16 21:20:02 +08:00
wangdefa d56678e1de 发布 v0.7.1
CI / test (push) Successful in 21s
Release / release (push) Successful in 40s
2026-07-16 16:42:29 +08:00
wangdefa 8897c847a1 审计改用日志搜索倒序,支持检索与无配额租户回退
CI / test (push) Successful in 31s
2026-07-16 16:36:31 +08:00
wangdefa b2252678dd 发布 v0.7.0
CI / test (push) Successful in 21s
Release / release (push) Successful in 59s
2026-07-16 12:59:52 +08:00
wangdefa 7002e42c06 美化项目与AI网关文档
CI / test (push) Successful in 21s
2026-07-16 12:47:57 +08:00
wangdefa da7b29d2e3 设置页AI Tab:网关设置入库、保险丝改维度与grok工具注入
CI / test (push) Successful in 33s
2026-07-16 12:31:36 +08:00
wangdefa 99b551401e 实例地址恒以主网卡为准,修复多网卡覆写随机 2026-07-16 12:31:36 +08:00
wangdefa 4e2bab3032 升级 oci-go-sdk 到 v65.121.0
CI / test (push) Successful in 44s
2026-07-15 14:23:01 +08:00
wangdefa e1f8a0539c AI网关:Responses直通codex兼容与流式升级回退
CI / test (push) Successful in 31s
Release / release (push) Successful in 39s
2026-07-15 10:39:56 +08:00
wangdefa a8bde89b56 更新 DASH_VERSION 至 v0.6.0
CI / test (push) Successful in 46s
Release / release (push) Successful in 43s
2026-07-14 19:33:33 +08:00
wangdefa 1da2197a6c 渠道模型列表与测试端点,修复探测无配额误判 2026-07-14 19:33:33 +08:00
148 changed files with 23683 additions and 1602 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.6
0.6.10
+132
View File
@@ -0,0 +1,132 @@
# Auth Login Methods(登录方式接入契约)
> 面板现有登录方式:密码(+可选 TOTP)、OIDC / GitHub(OAuth)、通行密钥(Passkey / WebAuthn)、Web3 钱包(EIP-4361)。
> 新增或修改任何登录方式时,以下契约是安全红线,缺一不可。
## 1. 登录守卫(防爆破)
公开登录端点的失败必须计入 `loginGuard`:`guardKey(clientIP, <账号标识>)`,账号未知用固定占位名(如 passkey 的 `__passkey__`),已知用其标识(如钱包地址小写)。锁定检查在校验之前;失败经 `auth.failLogin`(自带阈值转锁定与告警推送),成功 `guard.success`。阈值/时长取安全设置,不硬编码。
## 2. 系统日志留痕
公开登录端点不经系统日志中间件,handler 必须显式 `logs.Record` 成败(参考 `authHandler.recordLogin` / `passkeyHandler.recordLogin` / `walletHandler.recordWallet`);登录失败是安全关键事件,不允许无痕。JWT 组内的绑定/解绑由中间件自动留痕,无需额外埋点。
## 3. 令牌版本(TokenVersion)
绑定、解绑、凭据及认证因子变更均属敏感变更:必须在事务内
`bumpTokenVersionTx`(或 `RevokeSessions`)使旧 JWT 失效,并按第 6 节携带
`TokenProof`、接续操作者会话。因子写入与版本递增不得拆成两个独立提交。
## 4. 「至少一种登录方式」不变量
密码登录禁用要求至少一种在当前 `EffectiveAppURL` 下**可实际登录**的免密方式
(`usablePasswordlessTx`);地址为空时 Passkey、钱包和 OAuth 均不可用。禁用期间移除
最后一种免密方式被拒:解绑身份走 `ensureNotLastLogin`,删通行密钥走对称检查。
这些检查都在事务内先 `lockUserForAuthChange` 锁用户行,防并发绕过。新登录方式要
明确决策如何参与该不变量:
- **复用 `UserIdentity` 表**(如钱包,provider+subject 唯一)→ 走 `identityCountTx` 自动参与,零改动;
- **独立表**(如 Passkey 的 `UserPasskey`)→ 参照 `passkeyCountTx` 增加计数入口,并在开启门槛(`hasAnyPasswordless`)与两个 `ensureNot*` 检查里对称接入;api 层把 `ErrLastIdentity` 映射 409。
## 5. 挑战/状态的一次性消费
挑战-响应类流程(OAuth state、WebAuthn challenge、SIWE nonce)一律进程内 `map + mutex + TTL + gc`,取用即删(参考 `oauthPending` / `passkeyPending` / `walletPending`);不落库、不引 Redis。TTL 5-10 分钟。
## 6. 场景:已鉴权敏感事务的提交屏障
### 1. Scope / Trigger
凡请求先通过 JWT 中间件、随后仍会读取 body、等待外部回调或写入认证/安全配置,
都必须防止请求处理期间的 Logout、定点撤销或撤销全部会话被旧请求绕过。
### 2. Signatures
```go
type TokenProof struct {
Ver uint
Jti string
}
func tokenProofOf(c *gin.Context) service.TokenProof
func (s *AuthService) ensureTokenCurrentTx(
tx *gorm.DB, user *model.User, proof TokenProof,
) error
func (s *AuthService) RenewToken(
ctx context.Context, username, oldToken string, meta SessionMeta,
) (string, time.Time, error)
```
持久化契约使用 `user_sessions.token_id` 表示逻辑会话;敏感换发不改变该 JTI。
### 3. Contracts
- `RequireAuth` 必须把本次请求实际观察到的 `TokenVersion + JTI` 写入 Context
Handler 不得事后只按用户名读取“最新版本”代替 proof。
- 敏感事务、`RevokeSession` 与 Logout 的数据库路径统一先锁用户行,再检查/更新
会话行;持锁后调用 `ensureTokenCurrentTx`
- proof 通过后,认证因子写入、版本递增及事务内换发必须同成同败。需要在 Handler
末尾换发的旧接口,`RenewToken` 也须重新锁用户并确认当前版本恰为
`oldClaims.Ver + 1`
- 换发沿用旧 JWT 的合法 JTI;有旧会话行时只接续未撤销行,存在但已撤销或更新
失败必须关闭。兼容期无行 Token 可建行,但仍沿用旧 JTI,因此 Logout 的
tombstone 能同时撤销换发前后的 JWT。
- Logout 持久撤销须按 JTI 会话行解析稳定的 `user_id`,不得只按旧 JWT 的可变
username/subject 找用户;JTI tombstone 至少保留“旧 JWT 剩余寿命 +
一个完整 `tokenTTL`”,兜住 legacy 无行换发、改名提交和持久化撤销之间的
窄窗口。
- 外部身份登录须在用户锁内重查身份仍绑定;OAuth 登录还须在最终签发事务内重查
Provider 当前可用。
- proof 不二次检查自然到期:请求在 JWT 未过期时通过中间件即取得本次请求的认证
边界;不得为此额外引入长事务、分布式锁或持久状态机。
### 4. Validation & Error Matrix
| 条件 | 结果 |
| --- | --- |
| `user.TokenVersion != proof.Ver` | `ErrTokenStale` → HTTP 401 |
| JTI tombstone 命中或会话行已撤销 | `ErrTokenStale` → HTTP 401 |
| 旧会话行存在但接续命中 0 行 | 失败关闭,不得 fallback 创建 |
| 已验签当前版本的兼容 Token 确无会话行 | 沿用原 JTI 创建会话行 |
| 改名后用换发前 JWT Logout | 按 JTI/user_id 撤销新旧 JWT;旧 subject 不参与归属 |
| Handler 分段换发时版本不是 `oldVer + 1` | `ErrTokenStale`,前端重新登录 |
| 登录签发前身份已解绑 / Provider 已不可用 | 拒绝登录,不签发 JWT |
### 5. Good / Base / Bad Cases
- Good:TOTP 激活事务内依次锁用户、验 proof、写因子、递增版本、沿用 JTI 换发。
- Base:无会话行的存量 JWT 可完成一次合法变更,但换发后的 JWT 与旧 JWT 共用
JTI,后到 Logout 可同时撤销。
- Bad:proof 普通查询通过后释放锁,定点撤销完成,再把旧行更新失败当“无行”创建
新会话;这会复活已撤销 Token。
### 6. Tests Required
- TOTP 激活 × 撤销全部:旧 proof 返回 `ErrTokenStale`,因子与版本均不改变。
- proof check → 定点撤销 → renew:撤销后的行不得被新建替代。
- recorded 与 legacy 无行会话各测一次“改名后 old-token Logout”:换发 JWT
必须随同失效;recorded 行写入 `revoked_at`legacy tombstone 覆盖 fresh expiry。
- OAuth/钱包登录在最终事务前删除身份:不得签发会话;OAuth 禁用同理。
- Security/OAuth PATCH 使用已撤销 proof:返回 `ErrTokenStale` 且设置不落库。
### 7. Wrong vs Correct
```go
// Wrong:只按最新用户状态改因子,随后无条件签最新版本 JWT。
saveFactor()
bumpTokenVersion()
return IssueToken(ctx, username)
// Correct:请求 proof、因子写入、版本递增和会话接续共享事务与用户锁。
return db.Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil { return err }
if err := auth.ensureTokenCurrentTx(tx, user, proof); err != nil { return err }
return changeFactorAndRenewTx(tx, user, oldToken)
})
```
## 其他约定
- 面板地址依赖:RP ID / 回调 / SIWE domain 均派生自 `settings.EffectiveAppURL()`,为空时返回引导错误(文案对齐 `ErrOAuthNoAppURL`),不得回退猜测。
- 登录失败对外文案统一、不透出内部细节(防探测);错误变量集中定义在对应 service 文件头部。
- 新端点全量 swagger 中文注释并重新生成 docs/;providers 公开端点(`/auth/oauth/providers`)按需追加 `<method>Login` 可用性布尔,供登录页渲染入口。
+32
View File
@@ -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 退避),并发提速与限流保护成对出现,二者缺一不可。
+40 -42
View File
@@ -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,23 @@ 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 用例。
## GORM 列名与 map 更新
- GORM 默认命名把连续大写按公共缩写拆分:`UserOCID``user_oc_id``TenancyOCID``tenancy_oc_id`(不是 `user_ocid`)。用 `Updates(map[string]any{...})` 写裸列名前先确认真实列名(`schema.Parse` 或查建表 SQL),SQLite 下写错列名报 "no such column" 才暴露,MySQL/PG 同错。零值字段(如清空 `passphrase_enc`)必须走 map 更新,struct 更新会忽略零值。(2026-07-23 activate-api-key 切换签名用户)
+3 -1
View File
@@ -1,6 +1,6 @@
# 后端目录结构
> Go 后端工程的目录职责与代码组织约定。
> Go 后端工程(`oci-portal/`)的目录职责与代码组织约定。
## 目录职责
@@ -30,3 +30,5 @@ oci-portal/
- `defer` 紧跟资源获取语句,成对管理释放。
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
- 路由参数:资源 id 可能含 `/` 等 URL 保留字符时(如 OCI PAR id),一律经 query 传递而非路径参数 —— gin 路径段不匹配 `%2F`,会直接 404(2026-07 round6 教训)。
- OSP Gateway(账单/发票,`internal/oci/billing.go`):服务只在租户主区域提供,每个请求都带 `ospHomeRegion` 且客户端 `SetRegion` 到主区域 —— 主区域公共名经测活 `HomeRegionKey` + `RegionByKey` 解析,勿用凭据 region 直连;SDK 金额是 `*float32`,输出前经 `money()` 四舍五入抹掉 float64 转换噪音;`PayInvoice``Email` 是 API 必填(付款回执),service 层校验后透传。
+14
View File
@@ -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`)。
+91
View File
@@ -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 精简移除。
+6 -4
View File
@@ -1,6 +1,6 @@
# Backend Development Guidelines(oci-portal 后端规范)
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。
> Go 后端(`oci-portal/`)编码规范入口。
---
@@ -19,8 +19,10 @@ 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) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
| [Auth Login Methods](./auth-login-methods.md) | 登录方式接入契约(守卫/留痕/令牌版本/不变量/一次性挑战) | 已填 |
---
@@ -31,7 +33,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
@@ -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)
+26
View File
@@ -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」的强指纹;背景与证据分档见工作区任务归档 `.trellis/tasks/archive/2026-07/07-22-research-oci-caller-info/research/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`)。
+20 -1
View File
@@ -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.
+20 -19
View File
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
- 1.2 Research `[optional · repeatable]`
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My 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, Snow, Reasonix, Grok, Kimi Code (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
@@ -220,7 +220,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef
therefore must cover every required step from implementation through
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Gemini/Qoder/Copilot/ZCode/Reasonix/Trae and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions.
[workflow-state:in_progress]
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
@@ -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, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
- 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, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[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, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
Spawn the research sub-agent:
@@ -361,11 +361,11 @@ 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, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.)
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session.
[/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, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
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, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[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, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
| `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, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
---
@@ -472,21 +472,22 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
#### 2.1 Implement `[required · repeatable]`
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
Spawn the implement sub-agent:
- **Agent type**: `trellis-implement`
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
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
- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
Spawn the implement sub-agent:
@@ -498,7 +499,7 @@ The pull-based sub-agent definition auto-handles the context load requirement:
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
[/codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
[Kiro]
@@ -526,13 +527,13 @@ 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, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
Spawn the check sub-agent:
- **Agent type**: `trellis-check`
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
The check agent's job:
- Review code changes against specs
@@ -540,7 +541,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, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
+160
View File
@@ -2,6 +2,166 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.8.3]
### Added
- 新增通行密钥(Passkey / WebAuthn)注册、管理与免用户名登录,要求验证器完成用户验证;新增 Web3 钱包绑定与 EIP-4361 签名登录,支持浏览器注入的 EOA 钱包
- 新增活跃会话管理:按登录方式、客户端 IP、User-Agent 与最近活跃时间查看当前账号会话,可定点撤销其他会话;敏感变更换发令牌时接续原会话,升级前签发的存量令牌保持兼容
### Changed
- 租户 API Key 激活接口支持切换到租户内其他 IAM 用户,验证新密钥可用后同步更新面板签名用户与凭据
- 禁用密码登录时将通行密钥计入可用免密方式,并阻止删除最后一种可用登录方式,避免账号自锁
### Fixed
- 同一实例的终止与电源操作增加飞行期互斥,重复请求稳定返回 409,避免并发执行相互冲突的生命周期操作
## [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 提升到 16openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判
## [0.5.1]
### Fixed
+1 -1
View File
@@ -1 +1 @@
v0.5.1
v0.8.3
+87 -45
View File
@@ -6,42 +6,53 @@
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
![Release](https://img.shields.io/github/v/release/wangdefaa/oci-portal?display_name=tag)
![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8?logo=go&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-blue)
[![Release](https://img.shields.io/github/v/release/wangdefaa/oci-portal?display_name=tag)](https://github.com/wangdefaa/oci-portal/releases/latest)
[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8?logo=go&logoColor=white)](go.mod)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white)](docker-compose.yml)
[![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE)
[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [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 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
## 界面预览
| 总览 | 登录 |
| --- | --- |
| ![总览](docs/assets/screenshot-overview.png) | ![登录](docs/assets/screenshot-signin.png) |
<p align="center">
<img src="docs/assets/screenshot-overview.png" width="960" alt="OCI Portal 总览">
</p>
| 租户 | 任务 |
| --- | --- |
| ![租户](docs/assets/screenshot-tenants.png) | ![任务](docs/assets/screenshot-tasks.png) |
<details>
<summary><strong>展开更多界面截图</strong></summary>
| AI 网关 | 通知设置 |
| 登录 | 租户 |
| --- | --- |
| ![AI 网关](docs/assets/screenshot-ai-gateway.png) | ![通知设置](docs/assets/screenshot-settings-notify.png) |
| ![登录](docs/assets/screenshot-signin.png) | ![租户](docs/assets/screenshot-tenants.png) |
| 任务 | AI 网关 |
| --- | --- |
| ![任务](docs/assets/screenshot-tasks.png) | ![AI 网关](docs/assets/screenshot-ai-gateway.png) |
| 通知设置 |
| --- |
| ![通知设置](docs/assets/screenshot-settings-notify.png) |
</details>
## 核心能力
- **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥和口令使用 AES-256-GCM 加密落库
- **计算、网络与存储**:实例创建与电源操作、公网 IP、IPv6、VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额和成本查询
- **自动化任务**:抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知
- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
- **身份与审计**IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
- **通知与安全**Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
| 能力域 | 覆盖范围 |
| --- | --- |
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象、在线预览编辑、PAR 直传分享)与限额查询 |
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTPAES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、通行密钥(Passkey / WebAuthn,需 HTTPS 且换域名后需重新注册)、Web3 钱包签名登录(EIP-4361,仅注入钱包与 EOA)、登录锁定、IP 限速、活跃会话管理(按设备查看与定点撤销)和操作审计 |
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*``/ai/*` 实时请求 |
## 运行形态
@@ -57,7 +68,9 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
└─────────────────────────────────────────────────────────────────┘
```
默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。
> [!NOTE]
> 默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental
> 不应据此推断服务支持多副本并发运行。
## 快速开始
@@ -65,6 +78,10 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
前置条件:Docker、Docker Compose v2、OpenSSL。
> [!CAUTION]
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
1. 克隆仓库并生成一份需要长期保存的 `.env`
```bash
@@ -79,19 +96,27 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
chmod 600 .env
```
2. 准备数据目录并启动
2. 准备数据目录:
```bash
mkdir -p data
```
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写
Docker Desktop 用户通常不需要执行:
```bash
sudo chown 65532:65532 data
```
3. 启动并检查状态:
```bash
docker compose up -d
docker compose ps
```
3. 查看初始管理员密码并登录:
4. 查看初始管理员密码并登录:
```bash
grep '^ADMIN_PASSWORD=' .env
@@ -99,7 +124,14 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。
启动异常时查看最近日志:
```bash
docker compose logs --tail=100 oci-portal
```
> [!TIP]
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
### 二进制运行
@@ -107,6 +139,8 @@ Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64
```bash
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/SHA256SUMS
grep 'oci-portal-server-linux-amd64$' SHA256SUMS | sha256sum -c -
chmod +x oci-portal-server-linux-amd64
# 复用上文生成并妥善保存的 .env。
@@ -142,7 +176,9 @@ CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
## 生产部署
服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。
> [!WARNING]
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
### Caddy
@@ -229,29 +265,31 @@ OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`do
### 环境变量
| 变量 | 必填 | 默认值 | 说明 |
| 变量 | 使用条件 | 默认值 | 说明 |
| --- | :---: | --- | --- |
| `DATA_KEY` | | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
| `JWT_SECRET` | | — | JWT 签名密钥;更换会使已有登录令牌失效 |
| `ADMIN_USERNAME` | | `admin` | 初始管理员用户名 |
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
| `ADDR` | | `:8080` | HTTP 监听地址 |
| `DB_DRIVER` | | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
| `PUBLIC_URL` | | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
| `TZ` | | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
| `SWAGGER` | | 关闭 | 设为 `1` 时开放 Swagger UI |
| `GIN_MODE` | | `release` | `debug` / `release` |
| `DB_DSN` | MySQL / PostgreSQL | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
| `PUBLIC_URL` | 可选 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
| `TZ` | 可选 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 可选 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
| `SWAGGER` | 可选 | 关闭 | 设为 `1` 时开放 Swagger UI |
| `GIN_MODE` | 可选 | `release` | `debug` / `release` |
## 升级与备份
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
`data/oci-portal.db`,避免在线复制产生不一致快照。
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
会自动完成数据库迁移。
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
## 开发
@@ -268,6 +306,10 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
## 致谢
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
## License
[MIT](LICENSE)
+7 -2
View File
@@ -31,7 +31,7 @@ import (
)
// @title OCI Portal API
// @version 0.0.1
// @version 0.8.3
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
// @BasePath /
// @securityDefinitions.apikey BearerAuth
@@ -110,6 +110,7 @@ func run() error {
}
ociClient := oci.NewCachedClient(oci.NewClient())
ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
defer ociConfigs.Stop()
settings := service.NewSettingService(db, cipher)
settings.SetEnvPublicURL(cfg.PublicURL)
if err := settings.ReloadSecurity(context.Background()); err != nil {
@@ -118,17 +119,21 @@ func run() error {
notifier := service.NewNotifier(settings)
auth.SetNotifier(notifier, settings)
oauth := service.NewOAuthService(db, settings, auth)
passkeys := service.NewPasskeyService(db, settings, auth)
wallets := service.NewWalletService(db, settings, auth)
systemLogs := service.NewSystemLogService(db)
logEvents := service.NewLogEventService(db)
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
logEvents.SetNotifier(notifier, settings)
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
systemLogs.StartCleanup(cleanupCtx)
auth.StartSessionCleanup(cleanupCtx)
logEvents.StartParser(cleanupCtx)
logEvents.StartCleanup(cleanupCtx)
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
aiGateway.StartCleanup(cleanupCtx)
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
defer auth.WaitSessionCleanup()
defer logEvents.Wait()
defer systemLogs.Wait()
defer notifier.Wait()
@@ -149,7 +154,7 @@ func run() error {
defer proxies.Wait()
// 「关于」页存储指标需知数据库形态
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
router := api.NewRouter(auth, oauth, passkeys, wallets, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
return serveHTTP(cfg.Addr, router)
}
+498 -195
View File
@@ -1,294 +1,597 @@
<a id="top"></a>
<div align="center">
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
# AI 网关
> 本文是 OCI Portal AI 网关的完整使用与兼容性文档:端点定位、协议兼容边界、已知上游限制与字段兼容矩阵。
> 路由与鉴权的机器可读定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 表达的兼容边界以本文为准。
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...``x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
![OpenAI](https://img.shields.io/badge/OpenAI-Responses%20%7C%20Chat-412991?logo=openai&logoColor=white)
![Anthropic](https://img.shields.io/badge/Anthropic-Messages-D97757?logo=anthropic&logoColor=white)
![xAI](https://img.shields.io/badge/xAI-TTS%20%7C%20Tools-111111?logo=x&logoColor=white)
![Streaming](https://img.shields.io/badge/Streaming-SSE-0F9D58)
| 端点 | 定位 | 流式 |
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
</div>
> [!NOTE]
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
> 兼容改写和实测边界以本文为准。
| 文档属性 | 当前值 |
| --- | --- |
| 兼容快照 | **2026-07-16** |
| API 基址 | `/ai/v1` |
| 首选对话协议 | OpenAI Responses |
| 会话模式 | 无状态,客户端携带完整上下文 |
<a id="quick-start"></a>
## 快速接入
### 基础地址与鉴权
网关密钥在管理面板中创建。连接信息如下:
| 项目 | 配置 |
| --- | --- |
| Base URL | `https://<网关地址>/ai/v1` |
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
| API Key 鉴权 | `x-api-key: sk-...` |
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
列表与路由中移除。
可先用模型列表验证地址与密钥:
```bash
curl "https://<网关地址>/ai/v1/models" \
-H "Authorization: Bearer $OCI_PORTAL_KEY"
```
再发起一条最小 Responses 请求;请将示例模型替换为模型列表中的可见模型:
```bash
curl "https://<网关地址>/ai/v1/responses" \
-H "Authorization: Bearer $OCI_PORTAL_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"xai.grok-4.3","input":"你好,请用一句话介绍自己。"}'
```
<a id="endpoints"></a>
## 端点一览
| 协议域 | 端点 | 角色 | 流式 |
| --- | --- | --- | :---: |
| 对话 | `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
| 对话 | `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
| 对话 | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
| 向量 | `POST /ai/v1/embeddings` | OpenAI Embeddings | — |
| 语音 | `POST /ai/v1/audio/speech` | OpenAI Audio Speech 外壳 | — |
| 语音 | `POST /ai/v1/tts` | xAI TTS 格式转换层 | — |
| 检索 | `POST /ai/v1/rerank` | Cohere / Jina 风格文档重排 | — |
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
### 如何选择协议
| 使用场景 | 推荐接口 |
| --- | --- |
| 新客户端、推理模型、服务端工具 | **Responses** |
| Anthropic SDK、Claude 生态客户端 | **Messages** |
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
| 向量、语音、重排与安全审核 | 对应专用端点 |
<a id="routing"></a>
## 路由与全局行为
### 请求链路
```mermaid
flowchart LR
A[客户端] --> B[网关密钥鉴权]
B --> C[分组、白名单与全局过滤]
C --> D[模型与能力匹配]
D --> E[选择最小 Priority]
E --> F[同优先级按 Weight 加权]
F --> G[OCI GenAI]
G -. 可重试且流未建立 .-> D
```
单次请求最多尝试三个渠道。模型不可用、限流、上游服务错误或网络错误可触发
换渠道;流式连接建立后不会切换渠道重试。
### 全局兼容边界
| 主题 | 当前行为 |
| --- | --- |
| 会话状态 | 网关不保存会话历史,客户端必须在每次请求中携带完整上下文 |
| 上游存储 | Responses 请求始终强制 `store:false` |
| 有状态字段 | Responses 拒绝非空 `previous_response_id`、非 `null``conversation``background:true` |
| 对话上游 | 对话请求统一进入 OCI OpenAI 兼容面,当前供给以 `xai.``meta.``openai.` 前缀模型为主 |
| 推理强度 | Responses `reasoning.effort`、Messages `output_config.effort`、Chat `reasoning_effort` 会传给上游;可用档位由模型决定 |
| 服务端工具 | Responses 支持 `web_search``x_search``code_interpreter` 和远程 `mcp`;命名容器管理与 File Search 不提供 |
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
### 可配置运行策略
| 策略 | 默认行为 | 配置入口 |
| --- | --- | --- |
| `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,文本转语音(xAI Voice | 否 |
| `POST /ai/v1/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 |
| `POST /ai/v1/rerank` | 文档重排(Cohere RerankJina 风格协议) | 否 |
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
| Responses 流式保险丝 | 开启;阈值 `60 KB`,按 `instructions``tools` 两个字段的原始 JSON 值计算 | **设置 → AI → 流式保险丝** |
| Grok 服务端搜索 | 对 `xai.` 模型默认注入 `web_search``x_search`;同名工具不重复覆盖 | **设置 → AI → Grok 服务端搜索工具** |
| 弃用模型过滤 | 开启后从模型列表、请求路由与探测候选中统一排除 | **设置 → AI → 模型治理** |
兼容边界:
<a id="codex"></a>
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.``meta.``openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation``background:true`
- Responses 支持 Oracle 文档化的 xAI 服务端工具 `web_search` / `x_search` / `code_interpreter` 与远程 `mcp` 工具(非流式与流式均可),工具参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools)`code_interpreter` 的命名容器管理(containers API)与 File Search 不提供
- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供
- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试
- Audio Speech 直通 OCI 兼容面(模型 `xai.grok-tts`voice 取 xAI Grok Voice 列表:`ara`/`eve`/`leo`/`rex`/`sal`);上游把 `language` 当必填,缺省时网关自动注入 `"auto"`xAI 专属参数(`output_format` 等)可平铺在请求体透传;仅单请求返回音频,不提供 WebSocket 流式
- `/ai/v1/tts` 为 xAI 官方 TTS 格式(`text`/`language` 必填、`voice_id``output_format` 对象)的转换端点:网关转换为 OpenAI 兼容形态后走同一上游与渠道调度,`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format`(codec/sample_rate/bit_rate)与 `speed` 透传生效
- Rerank 走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),请求 `{model, query, documents[], top_n?, return_documents?}`,响应 `results[].index` 指向入参下标;无 token 用量口径,调用日志只记时延
- Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails`input` 为字符串或字符串数组(至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(阈值 0.5 判定 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged);`model` 字段接受但忽略,无模型白名单维度;实测中文人名/手机号识别较弱,英文 PII 识别正常
- Responses 的 `input_file` 内容块(file_url / file_data)实测被上游拒绝:`File content is currently unsupported for ZDR customers`——网关强制 `store:false` 属 ZDR 形态,该能力在上游侧不可用
## Codex 接入
### 已知上游限制:大 system 区流式断流
### 主配置
实测(2026-07-13OCI 兼容面对 `instructions``tools` 合计超约 64.5KB 的**流式**请求会在发出少量事件后静默断开连接(无任何错误事件;同请求非流式正常),与模型、字符集、消息正文大小均无关——消息正文(`input`)不计入该限制。Chat Completions 与 Messages 的 system/developer 提示会转换为 `instructions`,因此 Claude Code 等自带大体量系统提示与工具定义的客户端极易触发。
自定义模型提供方必须写在用户级 `~/.codex/config.toml`。Codex 的项目级
`.codex/config.toml` 不允许改写 `model_provider``model_providers`
网关侧应对:
```toml
model = "xai.grok-4.3"
model_provider = "oci"
- Messages 与 Chat Completions 的流式请求在客户端尚未收到任何输出时遭遇上游断流,会自动降级为非流式重做,并按标准事件/chunk 序列一次推送;调用日志记 `retries=1` 与降级标记
- Responses 直通因初始事件已转发、协议上无法透明降级,调用日志记「上游流提前终止」,客户端需自行回退非流式
- 应急规避:将超长 system 内容移入首条 user 消息正文可绕过该限制(正文不计入),但语义有别,根治有待上游修复
[model_providers.oci]
name = "oci-portal"
base_url = "https://<网关地址>/ai/v1"
env_key = "OCI_PORTAL_KEY"
wire_api = "responses"
```
这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
### 自定义子代理
### 字段兼容矩阵
新版 Codex 可在 `~/.codex/agents/` 或项目级 `.codex/agents/` 放置独立 TOML
文件,并为子代理覆盖模型。每个文件都需要 `name``description`
`developer_instructions`
以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)、[Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)、[Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对;Rerank 无 OpenAI 对应端点,基线取 [Cohere Rerank](https://docs.cohere.com/reference/rerank) 协议。
```toml
# .codex/agents/oci-worker.toml
name = "oci-worker"
description = "通过 OCI Portal 网关执行通用开发任务。"
developer_instructions = """
完成被分配的开发任务,保持改动聚焦,并返回验证结果。
"""
model = "xai.grok-4.3"
```
这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
> [!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 上游决定 |
| 🔄 | 网关行字段或协议转换后支持 |
| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
| 🔄 | 网关行字段或协议转换后支持 |
| ◐ | 部分支持、存在前置条件或语义降级 |
<details>
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
<a id="compat-responses"></a>
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
### OpenAI Responses
**`POST /ai/v1/responses` · 无状态主接口**
网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
<details open>
<summary><strong>核心字段</strong></summary>
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
| `instructions``max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
| `temperature``top_p``parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
| `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` | | 普通请求、`function`服务端工具均支持 SSE |
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
| `tools[].type=web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具,参数与限制遵循 xAI 规格(如 `allowed_domains` 上限 10、`container` 支持 `{"type":"auto"}`),非流式与流式均实测可用;`x_search` 不是 OpenAI 标准工具 |
| `tools[].type=mcp` | ➡️ | 远程 MCP 服务由上游直连调用(`server_url` / `require_approval` / `authorization` 等原样透传),非流式与流式均实测可用 |
| 其余标准与未知顶层字段 | ➡️ | `context_management``include``metadata``prompt``prompt_cache_key``service_tier``truncation``user` 等未建模字段一律保留在原始请求中,由 OCI 决定是否接受 |
不支持(请求到达上游前返回 400):非空 `previous_response_id`、非 `null``conversation``background:true`——网关无状态,不保存历史响应;以及 `function` / xAI 服务端工具 / `mcp` 之外的工具类型(`web_search_preview``file_search``computer``image_generation``shell``custom` 等)。
响应边界:
- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志
- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤
- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件
- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同
实现依据:[`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) · [`aigateway.go`](../internal/api/aigateway.go)
| `stream` | | 支持 SSE`instructions` `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations) |
| 其余标准与未知顶层字段 | ➡️ | `context_management``include``metadata``prompt``prompt_cache_key``service_tier``truncation``user` 等均保留,由 OCI 决定是否接受 |
</details>
<details>
<summary><code>POST /ai/v1/chat/completions</code> 对比 OpenAI Chat Completions</summary>
<summary><strong>工具兼容</strong></summary>
Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。转换器只搬运下表字段,其余字段不进入上游请求。
| 工具或参数 | 状态 | 网关行为 |
| --- | :---: | --- |
| `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``image_url.detail` 忽略 |
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
| `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` | 🔄 | OCI Responses SSE 桥接`chat.completion.chunk`,末尾补 `data: [DONE]`;上游断流且尚无输出时自动降级非流式重做,结果按 chunk 序列一次推送 |
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` usage 块 |
| `tools[].type=function` | ◐ | `name``description``parameters` 支持;`function.strict` 忽略 |
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
| `response_format` | ◐ | `json_object``json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
| `store` | 🔄 | 客户端取值忽略,转换后的上游请求始终使用 `store:false` |
| `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` |
不支持(返回 400):`input_audio``file``refusal` 等消息内容块;`function` 以外的工具类型(含 `custom`)。
</details>
静默忽略(转换后的上游请求不包含):消息级 `name` / `refusal` / `audio` / 旧式 `function_call`;采样与输出控制类 `stop``seed``n`(恒返回单个 choice)、`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` 及其他未知字段。
不支持(返回 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`
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
- 非流式固定生成一个 `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 错误事件
实现依据:[`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) · [`aigateway.go`](../internal/api/aigateway.go)
<a id="compat-messages"></a>
</details>
### Anthropic Messages
**`POST /ai/v1/messages` · Anthropic 协议转换层**
支持 `Authorization: Bearer``x-api-key`,但不校验或使用
`anthropic-version``anthropic-beta` 请求头。
<details>
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
<summary><strong>展开顶层字段矩阵</strong></summary>
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
| `input` 为字符串 | | 包装为单个输入后调用 OCI |
| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI;空字符串不在本地拒绝,由 OCI 决定 |
| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 |
| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 |
不支持(返回 400):token ID 数组(一维或二维)形态的 `input`、空数组、`null`,以及 `encoding_format=base64`。静默忽略:`user` 及其他未知字段。
响应使用标准的 `object:"list"``data[].object:"embedding"``index``model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。
实现依据:[`embeddings.go`](../internal/aiwire/embeddings.go) · [`aigateway_chat.go`](../internal/service/aigateway_chat.go) · [`aigateway.go`](../internal/api/aigateway.go)
</details>
<details>
<summary><code>POST /ai/v1/messages</code> 对比 Anthropic Messages</summary>
网关接受 `Authorization: Bearer``x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version``anthropic-beta` 请求头。
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
| `max_tokens` | 🔄 | 可缺省(缺省或 ≤0 时按默认值 8192),转换为 `max_output_tokens` |
| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
| `max_tokens` | 🔄 | 缺省或 ≤0 时使用 8192,再转为 `max_output_tokens` |
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control`附加字段忽略 |
| `temperature``top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列;上游断流且尚无输出时自动降级非流式重做,结果按事件序列一次推送 |
| `tools` | ◐ | 每个工具都转换成 Responses `function`自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
| `tool_choice` | ◐ | 支持 `auto``any``none`、具名 `tool``disable_parallel_tool_use` 等附加字段被忽略 |
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
| `temperature``top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
| `tool_choice` | ◐ | 支持 `auto``any``none`、具名 `tool` |
| `output_config.effort` | 🔄 | 转小写后映射为 `reasoning.effort` |
顶层静默忽略(能解析但不传上游):`top_k``stop_sequences`(响应 `stop_sequence` 恒为 `null`)、`metadata``thinking`(不控制上游思考预算)、`output_config` 其余子字段、`cache_control``container``inference_geo``service_tier` 及其他未知顶层字段。
</details>
`messages[].content`
内容块兼容
| 标准内容块 | 状态 | 网关行为 |
| `messages[].content` | 状态 | 网关行为 |
| --- | :---: | --- |
| 字符串 / `text` | 🔄 | user 转 `input_text`assistant 历史转 `output_text` |
| `image` | ◐ | 仅支持 `base64` `url` source;缺字段或其他 source 类型返回 400 |
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称输入 |
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text`is_error` 非文本结果丢失 |
| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
| `image` | ◐ | 仅支持 `base64` `url` source |
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称输入 |
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text`is_error` 非文本结果丢失 |
| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
不支持的内容块(返回 400):`document`、服务端工具结果及其他未知块。历史 `thinking` / `redacted_thinking` 块会被删除,不进入上游上下文。
不支持(返回 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``stop_sequence` 恒为 `null`
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
- usage 只保留 `input_tokens``output_tokens` `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
- 流式输出标准事件骨架,但不生成 `thinking_delta``signature_delta`上游错误事件与无终态断流会转成 Anthropic `error` 事件
- 仅将 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` 事件
实现依据:[`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) · [`aigateway.go`](../internal/api/aigateway.go)
<a id="compat-embeddings"></a>
</details>
### OpenAI Embeddings
<details>
<summary><code>POST /ai/v1/audio/speech</code> 对比 OpenAI Audio Speech</summary>
Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除缺省注入 `language` 外不重建请求体,未知字段原样透传。
**`POST /ai/v1/embeddings` · 向量化专用端点**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `TTS` 能力渠道(当前上游仅 `xai.grok-tts` |
| `input` | | 必填非空,随后原样透传 |
| `voice` | ➡️ | 原样透传;取 xAI Grok Voice 音色(`ara` / `eve` / `leo` / `rex` / `sal`),OpenAI 标准音色名不可用 |
| `response_format` | ➡️ | 原样透传;实测 `mp3` 可用,其余格式由上游决定 |
| `language`(扩展字段) | 🔄 | 上游必填;客户端缺省时网关自动注入 `"auto"` |
| `speed``instructions` 及其他未知字段 | ➡️ | 原样透传(含 xAI 专属参数如 `output_format`),是否生效由上游决定 |
| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
| 字符串 `input` | | 包装为单个输入后调用 OCI |
| 字符串数组 `input` | | 按原顺序调用 OCI |
| `dimensions` | | 映射为 OCI 输出维度,不校验范围或模型能力 |
| `encoding_format=float` | | 返回 float 数组;省略时相同 |
不支持:流式音频(`stream_format` 等流式选项无效,响应恒为一次性完整音频)与 WebSocket 语音会话。
不支持(返回 400):token ID 数组、空数组、`null`
`encoding_format=base64``user` 与其他未知字段静默忽略。
响应边界:
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`
- 无 token 用量口径,调用日志只记时延与渠道
<a id="compat-audio"></a>
实现依据:[`genai_speech.go`](../internal/oci/genai_speech.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
### 语音生成
</details>
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
<details>
<summary><code>POST /ai/v1/tts</code> 对比 xAI TTS</summary>
#### OpenAI Audio Speech
`/ai/v1/tts` 接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)(OCI 无 HTTP 版 xAI 面,网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与调度)。
**`POST /ai/v1/audio/speech`**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `text` | 🔄 | 必填非空,转换为上游 `input` |
| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 |
| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) |
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) |
| `speed` | ➡️ | 原样透传,实测接受 |
| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 |
| `optimize_streaming_latency``text_normalization``with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 |
| `model` | | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
| `input` | ◐ | 必填非空,随后保留 |
| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
| `speed``instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。
#### xAI TTS
响应边界:
**`POST /ai/v1/tts`**
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`)
实现依据:[`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
</details>
<details>
<summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary>
Rerank 无 OpenAI 对应端点,协议取 Jina / Cohere 通行风格,上游走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`)。
接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)
转换为 OpenAI 兼容形态后进入同一上游。
| 字段 | 状态 | 网关行为 |
| --- | :---: | --- |
| `model` | | 必填,受密钥白名单限制,并且必须存在具有 `RERANK` 能力的渠道 |
| `query` | | 必填非空 |
| `documents` | | 必填,仅接受字符串数组;Cohere 旧版 `{"text": ...}` 对象数组形态返回 400 |
| `top_n` | | 可选,传给上游限制返回条数;缺省返回全部文档的重排结果 |
| `return_documents` | | 可选,`true``results[].document.text` 回带原文 |
| `text` | 🔄 | 必填非空,转换为 `input` |
| `language` | | 必填,接受 BCP-47 或 `auto` |
| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
| `speed` | ➡️ | 保留,实测可用 |
| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
静默忽略:`max_tokens_per_doc` 等 Cohere 专属参数及其他未知字段。
共同响应边界:
响应边界:
- `results[]` 按相关度降序,`index` 指向入参 `documents` 下标,`relevance_score` 为 01 浮点;响应 `model` 回显请求值
- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
- 不提供 HTTP 流式音频或 WebSocket 代理
- 无 token 用量口径,调用日志只记时延与渠道
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
<a id="compat-rerank"></a>
</details>
### Rerank
<details>
<summary><code>POST /ai/v1/moderations</code> 对比 OpenAI Moderations</summary>
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入)。上游是服务级 API,没有模型与白名单维度;渠道按分组直接挑选
上游走 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 条 |
| 字符串 `input` | ✅ | 单条审核 |
| 字符串数组 `input` | ✅ | 逐条审核,单次 18 条 |
不支持(返回 400):多模态 input(图片等对象数组形态)、空字符串条目、空数组或超过 8 条的数组。静默忽略:`model`(接受任意值,不校验白名单)及其他未知字段
不支持(返回 400):多模态 input、空字符串条目、空数组或超过 8 条。
`model` 接受但忽略,不参与白名单判断。
响应边界:
- `categories` / `category_scores` 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`,而非 OpenAI 标准类目(`hate` / `violence` 等);任一维度得分 ≥ 0.5 判定 `flagged`
- PII 命中放扩展字段 `results[].pii``text` / `label` / `score` / `offset` / `length`),不参与 `flagged` 判定;实测中文人名 / 手机号识别较弱,英文 PII 识别正常
- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
- 任一维度得分 ≥ 0.5 时 `flagged=true`
- PII 命中放在扩展字段 `results[].pii`,包含 `text``label``score`
`offset``length`,不参与 `flagged`
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
- 响应 `model` 恒为 `oci-guardrails`
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`moderations.go`](../internal/aiwire/moderations.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
<a id="compat-models"></a>
</details>
### Models
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object``data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。面板「过滤弃用模型」开关开启时,OCI 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。
**`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)
+117
View File
@@ -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 请求暴露面与多租户关联风险**
![OCI SDK](https://img.shields.io/badge/OCI-Go%20SDK-F80000?logo=oracle&logoColor=white)
![Auth](https://img.shields.io/badge/Auth-API%20Key-2563EB)
![Scope](https://img.shields.io/badge/Scope-Multi--tenant-0F9D58)
[结论速览](#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.gov65.121.0](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go)
- [OCI Go SDK http_signer.gov65.121.0](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go)
- [OCI Go SDK configuration.gov65.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>
+2830 -23
View File
File diff suppressed because it is too large Load Diff
+2830 -23
View File
File diff suppressed because it is too large Load Diff
+1841 -23
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -4,11 +4,13 @@ go 1.26.5
require (
github.com/coreos/go-oidc/v3 v3.19.0
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.17.4
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.122.0
github.com/pquerna/otp v1.5.0
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
@@ -35,6 +37,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -47,10 +50,13 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.10.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
@@ -66,15 +72,18 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
+26 -4
View File
@@ -25,8 +25,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -61,6 +67,12 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -71,11 +83,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -119,10 +135,12 @@ 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.122.0 h1:yB587yZUGe/syyyp1CHlW74EXo38Rfye5vD3Ox4Cdq4=
github.com/oracle/oci-go-sdk/v65 v65.122.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -164,12 +182,16 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+121 -7
View File
@@ -3,6 +3,7 @@ package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -140,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
//
// @Summary 分页查询内容日志
// @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiCallLog]
// @Success 200 {object} pagedResponse[model.AiContentLog]
// @Security BearerAuth
// @Router /api/v1/ai-content-logs [get]
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
@@ -274,6 +275,55 @@ 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 ---- 聚合模型与调用日志 ----
@@ -350,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
// @Summary AI 调用日志列表
// @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiContentLog]
// @Success 200 {object} pagedResponse[model.AiCallLog]
// @Security BearerAuth
// @Router /api/v1/ai-logs [get]
func (h *aiAdminHandler) listLogs(c *gin.Context) {
@@ -373,9 +423,51 @@ 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 网关全局设置。
@@ -386,15 +478,16 @@ type aiSettingsResponse struct {
// @Security BearerAuth
// @Router /api/v1/ai-settings [get]
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
c.JSON(http.StatusOK, h.currentAiSettings())
}
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
//
// @Summary 更新 AI 网关全局设置
// @Tags AI 管理
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 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) {
@@ -403,9 +496,30 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
if req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
return
}
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
}
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
if err := h.gw.SetStreamGuard(ctx, req.StreamGuardEnabled, req.StreamGuardKB); err != nil {
respondError(c, err)
return
}
if err := h.gw.SetGrokSearch(ctx, req.GrokWebSearch, req.GrokXSearch); err != nil {
respondError(c, err)
return
}
if err := h.gw.SetUpstreamWait(ctx, req.UpstreamWaitSeconds); err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, h.currentAiSettings())
}
+112 -12
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"slices"
"strings"
@@ -552,7 +553,7 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
//
// @Summary OpenAI Responses 兼容端点
// @Tags AI 网关
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;未列字段原样透传上游)"
// @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) {
@@ -576,16 +577,27 @@ func (h *aiGatewayHandler) responses(c *gin.Context) {
// 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, err := service.RespPassthroughBody(raw)
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.responsesPassthroughStream(c, body, req)
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()
@@ -597,6 +609,7 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
h.logFailure(c, entry, req)
return
}
payload = service.RespRestoreToolCalls(payload, compat)
entry.Status = http.StatusOK
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
@@ -604,9 +617,75 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
}
// 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()
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, req)
return
}
payload = service.RespRestoreToolCalls(payload, compat)
writeSynthSSE(c, payload)
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", req.Model, true, req, json.RawMessage(payload))
}
// writeSynthSSE 把完整响应按合成事件序列写出;合成失败时降级为一次性 JSON,
// 客户端至少拿到完整结果。
func writeSynthSSE(c *gin.Context, payload []byte) {
events, err := service.RespSynthSSEEvents(payload)
if err != nil {
log.Printf("responses 直通: 合成 SSE 失败,降级 JSON 返回: %v", err)
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
return
}
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 记账
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
// 逐行扫描 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)
@@ -618,7 +697,7 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
}
defer upstream.Close()
sseHeaders(c)
usage, upErr, err := forwardSSE(c, upstream)
usage, upErr, err := forwardSSE(c, upstream, compat)
if err != nil {
entry.ErrMsg = err.Error()
} else if upErr != "" {
@@ -635,25 +714,29 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
}
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, error) {
// 顺带从 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 {
c.Writer.Write(line)
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
c.Writer.Flush()
} else if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
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 {
@@ -665,3 +748,20 @@ func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, erro
}
}
}
// 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
}
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
@@ -29,7 +30,7 @@ func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
func TestAiGatewayKeyModelRestrict(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -125,7 +126,7 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -172,7 +173,7 @@ func TestAiGatewayModelsFilteredByKey(t *testing.T) {
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
r, auth, _, _ := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
+28 -1
View File
@@ -10,7 +10,8 @@ import (
// ---- 实例 IP ----
// @Summary ---- 实例 IP ----
// @Summary 更换实例主网卡临时公网 IP
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
@@ -35,6 +36,32 @@ 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"
+5 -5
View File
@@ -3,7 +3,6 @@ package api
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -34,7 +33,9 @@ type loginRequest struct {
// @Param body body loginRequest true "登录凭据"
// @Success 200 {object} tokenResponse "token 与 expiresAt"
// @Failure 401 {object} errorResponse "凭据错误"
// @Failure 403 {object} errorResponse "密码登录已禁用"
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
// @Failure 429 {object} errorResponse "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited"
// @Router /api/v1/auth/login [post]
func (h *authHandler) login(c *gin.Context) {
var req loginRequest
@@ -43,7 +44,7 @@ func (h *authHandler) login(c *gin.Context) {
return
}
start := time.Now()
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode)
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, req.TotpCode, sessionMetaOf(c))
if errors.Is(err, service.ErrTotpRequired) {
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
@@ -80,9 +81,8 @@ func (h *authHandler) login(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/auth/logout [post]
func (h *authHandler) logout(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if ok && token != "" {
h.svc.Logout(token)
if token := bearerToken(c); token != "" {
h.svc.Logout(c.Request.Context(), token)
}
c.Status(http.StatusNoContent)
}
+64 -34
View File
@@ -16,9 +16,11 @@ import (
// authxHandler 处理两步验证与外部身份(OAuth)接口。
type authxHandler struct {
auth *service.AuthService
oauth *service.OAuthService
logs *service.SystemLogService
auth *service.AuthService
oauth *service.OAuthService
passkeys *service.PasskeyService
wallets *service.WalletService
logs *service.SystemLogService
}
// ---- TOTP(JWT 组内) ----
@@ -65,7 +67,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
// @Summary 激活两步验证
// @Tags 认证
// @Param body body object true "{code: 6 位验证码}"
// @Success 204 "已启用"
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Security BearerAuth
// @Router /api/v1/auth/totp/activate [post]
func (h *authxHandler) totpActivate(c *gin.Context) {
@@ -76,7 +79,9 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.ActivateTotp(c.Request.Context(), c.GetString(usernameKey), req.Code)
token, expires, err := h.auth.ActivateTotp(
c.Request.Context(), c.GetString(usernameKey), req.Code,
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -85,7 +90,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
@@ -93,7 +98,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
// @Summary 停用两步验证
// @Tags 认证
// @Param body body object true "{password 或 code 任一确认}"
// @Success 204 "已停用"
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Security BearerAuth
// @Router /api/v1/auth/totp/disable [post]
func (h *authxHandler) totpDisable(c *gin.Context) {
@@ -105,7 +111,9 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.DisableTotp(c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code)
token, expires, err := h.auth.DisableTotp(
c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code,
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrTotpConfirm) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -114,13 +122,14 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录
func (h *authxHandler) respondFreshToken(c *gin.Context) {
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
// 旧令牌全部失效);旧令牌的会话行接续到新令牌,列表中当前设备保持连续
// 签发失败降级 204,前端按会话失效走重新登录。
func respondFreshToken(c *gin.Context, auth *service.AuthService) {
token, expires, err := auth.RenewToken(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c))
if err != nil {
c.Status(http.StatusNoContent)
return
@@ -136,7 +145,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
// @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))
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
respondError(c, err)
return
@@ -170,7 +179,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
// @Summary 修改登录凭据
// @Tags 认证
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
// @Success 204 "已更新,请重新登录"
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
// @Success 204 "已更新但新 token 签发失败,需重新登录"
// @Failure 401 {object} errorResponse "当前密码错误"
// @Security BearerAuth
// @Router /api/v1/auth/credentials [put]
@@ -180,7 +190,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req, tokenProofOf(c))
if errors.Is(err, service.ErrCredentialConfirm) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
@@ -194,7 +204,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
return
}
c.Set(usernameKey, finalName)
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
@@ -202,7 +212,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
// @Summary 密码登录开关
// @Tags 认证
// @Param body body object true "{disabled: bool}"
// @Success 204 "已保存"
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
// @Success 204 "已保存但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "未绑定外部身份"
// @Security BearerAuth
// @Router /api/v1/auth/password-login [put]
@@ -214,7 +225,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled)
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled, tokenProofOf(c))
if errors.Is(err, service.ErrNeedIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
@@ -223,26 +234,31 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// ---- OAuth ----
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
// 并附带密码登录禁用开关与通行密钥登录可用性:开关开启且存在可用外部身份时,
// 登录页隐藏密码表单;provider 清空时不下发禁用,保证界面始终留有登录入口
// (后端 Login 仍会拒绝)。
//
// @Summary 外部登录 provider 列表
// @Tags 认证
// @Success 200 {object} oauthProvidersResponse "providerspasswordLoginDisabled"
// @Success 200 {object} oauthProvidersResponse "providerspasswordLoginDisabled 与 passkeyLogin"
// @Router /api/v1/auth/oauth/providers [get]
func (h *authxHandler) oauthProviders(c *gin.Context) {
providers := h.oauth.Providers(c.Request.Context())
passkey := h.passkeys != nil && h.passkeys.HasAny(c.Request.Context())
wallet := h.wallets != nil && h.wallets.HasAny(c.Request.Context())
disabled := false
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && len(providers) > 0 {
// 有任一免密入口(OAuth/通行密钥/钱包)才如实下发禁用状态;全部缺席时隐藏,
// 保证登录页始终留有入口(后端 Login 仍会拒绝)
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && (len(providers) > 0 || passkey || wallet) {
disabled = true
}
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled})
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled, "passkeyLogin": passkey, "walletLogin": wallet})
}
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
@@ -252,6 +268,9 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
// @Param provider path string true "oidc / github"
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
// @Success 200 {object} urlResponse "url"
// @Failure 401 {object} errorResponse "bind 模式未携带有效 Bearer"
// @Failure 404 {object} errorResponse "未知 provider"
// @Failure 409 {object} errorResponse "provider 未配置 / 面板地址缺失 / 已禁用"
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
provider := c.Param("provider")
@@ -268,7 +287,7 @@ func (h *authxHandler) oauthAuthorize(c *gin.Context) {
}
mode, username = "bind", name
}
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username)
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username, bearerToken(c))
if err != nil {
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -306,7 +325,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
start := time.Now()
provider := c.Param("provider")
token, username, mode, err := h.oauth.HandleCallback(
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
c.Request.Context(), provider, c.Query("state"), c.Query("code"), sessionMetaOf(c))
if err != nil {
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
target := "/login"
@@ -375,7 +394,8 @@ func (h *authxHandler) identities(c *gin.Context) {
// @Summary 解绑外部身份
// @Tags 认证
// @Param id path int true "身份 ID"
// @Success 204 "已解绑"
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
// @Security BearerAuth
// @Router /api/v1/auth/identities/{id} [delete]
@@ -385,7 +405,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id)); err != nil {
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
if errors.Is(err, service.ErrLastIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
@@ -393,7 +413,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// ---- OAuth provider 设置(JWT 组内) ----
@@ -414,21 +434,31 @@ 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
// @Failure 400 {object} errorResponse "请求体非法"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Failure 409 {object} errorResponse "密码登录禁用期间,该变更将移除最后可用登录方式"
// @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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := settings.UpdateOAuth(c.Request.Context(), req); err != nil {
err := settings.UpdateOAuthAuthenticated(
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrProviderLastLogin) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/oci" // swagger 注解引用
)
// payInvoiceRequest 是付款请求体;email 接收 OSP 付款回执。
type payInvoiceRequest struct {
Email string `json:"email" binding:"required"`
}
// @Summary 发票列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param year query int false "自然年过滤(按开票时间);缺省全量"
// @Success 200 {array} oci.Invoice
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices [get]
func (h *ociConfigHandler) invoices(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
year, _ := strconv.Atoi(c.Query("year"))
list, err := h.svc.Invoices(c.Request.Context(), id, year)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, list)
}
// @Summary 发票费用明细
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Success 200 {array} oci.InvoiceLine
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/lines [get]
func (h *ociConfigHandler) invoiceLines(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
lines, err := h.svc.InvoiceLines(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, lines)
}
// @Summary 发票 PDF 下载
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param number query string false "发票号(用作下载文件名)"
// @Success 200 {file} binary "PDF 原文"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pdf [get]
func (h *ociConfigHandler) invoicePdf(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
data, err := h.svc.InvoicePdf(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", pdfFileName(c.Query("number"), c.Param("internalId"))))
c.Data(http.StatusOK, "application/pdf", data)
}
// pdfFileName 生成下载文件名:优先发票号,剔除引号/路径分隔等不安全字符。
func pdfFileName(number, internalID string) string {
name := strings.TrimSpace(number)
if name == "" {
name = internalID
}
name = strings.Map(func(r rune) rune {
if r == '"' || r == '/' || r == '\\' || r < 0x20 {
return '_'
}
return r
}, name)
return name + ".pdf"
}
// @Summary 支付发票
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param body body payInvoiceRequest true "回执邮箱"
// @Success 200 {object} map[string]string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pay [post]
func (h *ociConfigHandler) payInvoice(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req payInvoiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.PayInvoice(c.Request.Context(), id, c.Param("internalId"), req.Email); err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"status": "submitted"})
}
// @Summary 付款方式列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Success 200 {array} oci.PaymentMethod
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/payment-methods [get]
func (h *ociConfigHandler) paymentMethods(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
methods, err := h.svc.PaymentMethods(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, methods)
}
+138 -3
View File
@@ -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)
}
}
// ---- FederationSAML IdP 与 sign-on 免 MFA ----
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
@@ -58,11 +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} oci.IdentityProviderInfo
// @Success 201 {object} createIdpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
@@ -89,11 +198,37 @@ 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 激活身份提供商
+217
View File
@@ -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", "", service.SessionMeta{ClientIP: "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())
}
}
}
+35 -2
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"sync"
"github.com/gin-gonic/gin"
@@ -44,6 +45,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"`
@@ -118,6 +120,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,
@@ -200,6 +203,7 @@ func (h *ociConfigHandler) updateInstance(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 204 "无内容"
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
@@ -207,20 +211,43 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
if !ok {
return
}
instanceID := c.Param("instanceId")
if !beginInstanceLifecycleOperation(instanceID) {
respondInstanceLifecycleBusy(c)
return
}
defer endInstanceLifecycleOperation(instanceID)
preserve := c.Query("preserveBootVolume") == "true"
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), preserve); err != nil {
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), instanceID, preserve); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// instanceLifecycleGuard 按实例 OCID 互斥 HTTP 请求飞行期内的终止与电源操作。
var instanceLifecycleGuard sync.Map
func beginInstanceLifecycleOperation(instanceID string) bool {
_, busy := instanceLifecycleGuard.LoadOrStore(instanceID, struct{}{})
return !busy
}
func endInstanceLifecycleOperation(instanceID string) {
instanceLifecycleGuard.Delete(instanceID)
}
func respondInstanceLifecycleBusy(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "该实例的生命周期操作正在处理中"})
}
// @Summary 实例电源操作(启停/重启)
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body instanceActionRequest true "请求体"
// @Success 200 {object} oci.Instance
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
@@ -233,7 +260,13 @@ func (h *ociConfigHandler) instanceAction(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Action)
instanceID := c.Param("instanceId")
if !beginInstanceLifecycleOperation(instanceID) {
respondInstanceLifecycleBusy(c)
return
}
defer endInstanceLifecycleOperation(instanceID)
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, instanceID, req.Action)
if err != nil {
respondError(c, err)
return
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func countLifecycleWinners(instanceID string, workers int) int {
start := make(chan struct{})
results := make(chan bool, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
results <- beginInstanceLifecycleOperation(instanceID)
}()
}
close(start)
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
return winners
}
func TestBeginInstanceLifecycleOperationSingleFlight(t *testing.T) {
instanceID := "ocid1.instance.oc1..single-flight"
instanceLifecycleGuard.Delete(instanceID)
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
if got, want := countLifecycleWinners(instanceID, 16), 1; got != want {
t.Errorf("winners = %d, want %d", got, want)
}
}
func instanceOperationRouter() *gin.Engine {
h := &ociConfigHandler{}
r := gin.New()
r.DELETE("/oci-configs/:id/instances/:instanceId", h.terminateInstance)
r.POST("/oci-configs/:id/instances/:instanceId/action", h.instanceAction)
return r
}
func TestInstanceLifecycleOperationRejectsLaterRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
instanceID := "ocid1.instance.oc1..same-physical-instance"
instanceLifecycleGuard.Store(instanceID, struct{}{})
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
r := instanceOperationRouter()
tests := []struct {
name, method, path, body string
}{
{"action先占位时跨配置terminate被拒", http.MethodDelete, "/oci-configs/2/instances/" + instanceID, ""},
{"terminate先占位时action被拒", http.MethodPost, "/oci-configs/1/instances/" + instanceID + "/action", `{"action":"STOP"}`},
{"action先占位时第二个action被拒", http.MethodPost, "/oci-configs/3/instances/" + instanceID + "/action", `{"action":"RESET"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got, want := w.Code, http.StatusConflict; got != want {
t.Errorf("status = %d, want %d, body %s", got, want, w.Body.String())
}
})
}
}
+30 -8
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
@@ -12,11 +13,17 @@ import (
"oci-portal/internal/service"
)
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键
const usernameKey = "username"
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键;
// tokenVerKey/tokenJtiKey 是鉴权时观察到的令牌快照,敏感事务提交前复核。
const (
usernameKey = "username"
tokenVerKey = "tokenVer"
tokenJtiKey = "tokenJti"
)
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
// 通过后把用户名放进上下文。
// 通过后把用户名与令牌快照(版本/jti)放进上下文——快照供敏感事务在
// 行锁下复核,防「请求挂起期间令牌被撤销,恢复后仍完成变更」的在途绕过。
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
@@ -24,16 +31,25 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
username, err := auth.ParseToken(c.Request.Context(), token)
username, proof, err := auth.ParseTokenProof(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
c.Set(usernameKey, username)
c.Set(tokenVerKey, proof.Ver)
c.Set(tokenJtiKey, proof.Jti)
c.Next()
}
}
// tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。
func tokenProofOf(c *gin.Context) service.TokenProof {
v, _ := c.Get(tokenVerKey)
ver, _ := v.(uint)
return service.TokenProof{Ver: ver, Jti: c.GetString(tokenJtiKey)}
}
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
func bodyLimit(n int64) gin.HandlerFunc {
@@ -45,7 +61,7 @@ func bodyLimit(n int64) gin.HandlerFunc {
}
}
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
// systemLogMiddleware 把写请求(POST/PUT/PATCH/DELETE)异步记入系统日志,只读请求跳过。
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -101,17 +117,23 @@ func errMsgOf(status int, head []byte) string {
return truncateLogField(body.Error, 256)
}
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽;
// 截断点回退到 rune 边界,避免产生非法 UTF-8(严格 MySQL/PG 会拒写整行)。
func truncateLogField(s string, max int) string {
if len(s) <= max {
return s
}
for max > 0 && !utf8.RuneStart(s[max]) {
max--
}
return s[:max]
}
// isWriteMethod 判断是否需要留痕的写方法
// isWriteMethod 判断是否需要留痕的写方法;PATCH 承载 OAuth / 安全设置等
// 敏感配置变更,必须与 POST/PUT/DELETE 一样进入审计。
func isWriteMethod(m string) bool {
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
return m == http.MethodPost || m == http.MethodPut ||
m == http.MethodPatch || m == http.MethodDelete
}
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"strings"
"testing"
"unicode/utf8"
)
// PATCH 承载 OAuth / 安全设置变更,必须纳入审计;只读方法不留痕。
func TestIsWriteMethod(t *testing.T) {
tests := []struct {
method string
want bool
}{
{http.MethodPost, true},
{http.MethodPut, true},
{http.MethodPatch, true},
{http.MethodDelete, true},
{http.MethodGet, false},
{http.MethodHead, false},
}
for _, tt := range tests {
if got := isWriteMethod(tt.method); got != tt.want {
t.Errorf("isWriteMethod(%s) = %v, want %v", tt.method, got, tt.want)
}
}
}
func TestTruncateLogField(t *testing.T) {
tests := []struct {
name string
in string
max int
want string
}{
{name: "短串原样", in: "abc", max: 10, want: "abc"},
{name: "ASCII 截断", in: "abcdef", max: 4, want: "abcd"},
{name: "中文边界回退", in: "中文日志", max: 4, want: "中"},
{name: "恰好落在边界", in: "中文", max: 3, want: "中"},
{name: "emoji 拦腰", in: "a😀b", max: 3, want: "a"},
{name: "超长 UA 不产非法序列", in: strings.Repeat("界", 200), max: 255, want: strings.Repeat("界", 85)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateLogField(tt.in, tt.max)
if got != tt.want {
t.Errorf("truncateLogField(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want)
}
if !utf8.ValidString(got) {
t.Errorf("result %q is not valid UTF-8", got)
}
})
}
}
+464
View File
@@ -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)
}
+6
View File
@@ -277,6 +277,12 @@ func respondError(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
// 请求处理期间令牌已失效(撤销/注销/并发敏感操作后到):按未认证处理,
// 前端「发送时令牌==当前令牌」快照决定是否登出
if errors.Is(err, service.ErrTokenStale) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.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})
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// passkeyHandler 处理通行密钥(WebAuthn)注册、登录与凭据管理接口。
type passkeyHandler struct {
svc *service.PasskeyService
auth *service.AuthService
logs *service.SystemLogService
}
// registerBegin 生成注册 options;sessionId 需原样带回 finish。
//
// @Summary 发起通行密钥注册
// @Tags 认证
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
// @Failure 409 {object} errorResponse "面板地址未设置或数量达上限"
// @Security BearerAuth
// @Router /api/v1/auth/passkey/register/begin [post]
func (h *passkeyHandler) registerBegin(c *gin.Context) {
sid, opts, err := h.svc.BeginRegister(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
if errors.Is(err, service.ErrPasskeyNoAppURL) || errors.Is(err, service.ErrPasskeyLimit) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
}
// passkeyFinishRequest 是注册/登录 finish 的请求体;credential 为浏览器
// navigator.credentials 返回的 WebAuthn JSON,原样透传后端解析。
type passkeyFinishRequest struct {
SessionId string `json:"sessionId" binding:"required,max=64"`
Name string `json:"name" binding:"max=64"`
Credential json.RawMessage `json:"credential" binding:"required"`
}
// registerFinish 校验注册响应并保存凭据;属敏感变更,换发新令牌。
//
// @Summary 完成通行密钥注册
// @Tags 认证
// @Param body body passkeyFinishRequest true "sessionId、名称与凭据响应"
// @Success 200 {object} tokenResponse "已注册,返回换发的新 token"
// @Success 204 "已注册但新 token 签发失败,需重新登录"
// @Failure 400 {object} errorResponse "请求体非法、会话过期或凭据校验失败"
// @Security BearerAuth
// @Router /api/v1/auth/passkey/register/finish [post]
func (h *passkeyHandler) registerFinish(c *gin.Context) {
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.FinishRegister(c.Request.Context(), c.GetString(usernameKey),
req.SessionId, req.Name, bytes.NewReader(req.Credential), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrPasskeySession) || errors.Is(err, service.ErrPasskeyVerify) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
respondFreshToken(c, h.auth)
}
// list 列出当前账号的通行密钥。
//
// @Summary 通行密钥列表
// @Tags 认证
// @Success 200 {object} itemsResponse[model.UserPasskey] "items"
// @Security BearerAuth
// @Router /api/v1/auth/passkeys [get]
func (h *passkeyHandler) list(c *gin.Context) {
items, err := h.svc.List(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// remove 删除通行密钥;属敏感变更,换发新令牌。
// 密码登录禁用期间,最后一个免密登录方式不可删除(防自锁)。
//
// @Summary 删除通行密钥
// @Tags 认证
// @Param id path int true "凭据 ID"
// @Success 200 {object} tokenResponse "已删除,返回换发的新 token"
// @Success 204 "已删除但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "密码登录已禁用且这是最后一个免密登录方式"
// @Security BearerAuth
// @Router /api/v1/auth/passkeys/{id} [delete]
func (h *passkeyHandler) remove(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.svc.Remove(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
if errors.Is(err, service.ErrLastIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
respondFreshToken(c, h.auth)
}
// loginBegin 生成登录断言 options(公开;无凭据也正常下发,不泄露账号状态)。
//
// @Summary 发起通行密钥登录
// @Tags 认证
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
// @Failure 409 {object} errorResponse "面板地址未设置"
// @Router /api/v1/auth/passkey/login/begin [post]
func (h *passkeyHandler) loginBegin(c *gin.Context) {
sid, opts, err := h.svc.BeginLogin(c.Request.Context())
if err != nil {
if errors.Is(err, service.ErrPasskeyNoAppURL) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
}
// loginFinish 校验断言并签发 JWT;凭 UV 豁免 TOTP。
//
// @Summary 完成通行密钥登录
// @Tags 认证
// @Param body body passkeyFinishRequest true "sessionId 与断言响应(name 忽略)"
// @Success 200 {object} tokenResponse "token 与 expiresAt"
// @Failure 400 {object} errorResponse "请求体非法"
// @Failure 401 {object} errorResponse "校验失败"
// @Failure 429 {object} errorResponse "连续失败已锁定"
// @Router /api/v1/auth/passkey/login/finish [post]
func (h *passkeyHandler) loginFinish(c *gin.Context) {
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
start := time.Now()
token, expires, username, err := h.svc.FinishLogin(
c.Request.Context(), req.SessionId, sessionMetaOf(c), bytes.NewReader(req.Credential))
if errors.Is(err, service.ErrLoginLocked) {
h.recordLogin(c, username, http.StatusTooManyRequests, "连续失败已锁定", start)
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
if err != nil {
// 统一 401 文案,不区分会话过期/校验失败,防探测
h.recordLogin(c, username, http.StatusUnauthorized, service.ErrPasskeyVerify.Error(), start)
c.JSON(http.StatusUnauthorized, gin.H{"error": service.ErrPasskeyVerify.Error()})
return
}
h.recordLogin(c, username, http.StatusOK, "", start)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// recordLogin 记录通行密钥登录成败——公开端点不经系统日志中间件,
// 登录失败是安全关键事件,必须留痕;失败时账号未知,username 为空。
func (h *passkeyHandler) recordLogin(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: http.MethodPost,
Path: requestPath(c),
Status: status,
DurationMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
ErrMsg: errMsg,
})
}
+3 -1
View File
@@ -77,7 +77,9 @@ func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
sec := settings.SecurityCached()
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
// code 供前端区分全局 IP 限流与登录守卫锁定(后者不带 code,留在表单内提示)
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"error": "rate limit exceeded", "code": "RateLimited"})
return
}
c.Next()
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
)
// swag 解析 @Success 里的 oci.ReservedIP 需要本文件持有该包导入
var _ = oci.ReservedIP{}
// ---- 保留公网 IP ----
// @Summary 保留 IP 列表
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param region query string false "区域,空为主区域"
// @Param compartmentId query string false "区间 OCID,空为生效区间"
// @Success 200 {array} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [get]
func (h *ociConfigHandler) listReservedIPs(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
ips, err := h.svc.ReservedIPs(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, ips)
}
// @Summary 创建保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:region、compartmentId、displayName"
// @Success 201 {object} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [post]
func (h *ociConfigHandler) createReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
CompartmentID string `json:"compartmentId"`
DisplayName string `json:"displayName"`
}
_ = c.ShouldBindJSON(&req)
ip, err := h.svc.CreateReservedIP(c.Request.Context(), id, req.Region, req.CompartmentID, req.DisplayName)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, ip)
}
// @Summary 绑定/解绑保留 IP
// @Description vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param body body object true "请求体:region、instanceId、vnicId"
// @Success 204 "绑定成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [put]
func (h *ociConfigHandler) assignReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
InstanceID string `json:"instanceId"`
VnicID string `json:"vnicId"`
}
_ = c.ShouldBindJSON(&req)
err := h.svc.AssignReservedIP(c.Request.Context(), id, req.Region, c.Param("publicIpId"), req.InstanceID, req.VnicID)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 删除保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param region query string false "区域"
// @Success 204 "删除成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [delete]
func (h *ociConfigHandler) deleteReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteReservedIP(c.Request.Context(), id, c.Query("region"), c.Param("publicIpId"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+11 -4
View File
@@ -10,7 +10,7 @@ import (
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
// 具体路由按域拆在 routes_*.go。/auth/login 公开,业务路由要求 JWT。
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
r := gin.New()
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
_ = r.SetTrustedProxies(nil)
@@ -23,17 +23,24 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
v1 := r.Group("/api/v1", bodyLimit(1<<20))
registerAuthPublic(v1, auth, oauth, systemLogs)
registerAuthPublic(v1, auth, oauth, passkeys, wallets, systemLogs)
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
registerAiGateway(r, aiGateway)
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
registerAuthSecured(secured, auth, oauth, settings, systemLogs)
registerAuthSecured(secured, auth, oauth, passkeys, settings, systemLogs)
registerConsole(v1, secured, console, auth)
registerSettings(secured, settings, notifier, systemLogs, proxies)
registerSettings(secured, auth, 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)
+14 -4
View File
@@ -56,6 +56,14 @@ 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)
@@ -78,7 +86,7 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.User{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.UserPasskey{}, &model.UserSession{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-key")
@@ -96,7 +104,9 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
systemLogs := service.NewSystemLogService(db)
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{}))
passkeys := service.NewPasskeyService(db, settings, auth)
wallets := service.NewWalletService(db, settings, auth)
r := NewRouter(auth, oauth, passkeys, wallets, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
return r, auth, systemLogs, db
}
@@ -138,7 +148,7 @@ func TestLoginEndpoint(t *testing.T) {
func TestSecuredRoutesRequireToken(t *testing.T) {
r, auth, _ := newTestRouter(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -163,7 +173,7 @@ func TestSecuredRoutesRequireToken(t *testing.T) {
func TestSystemLogsEndpoint(t *testing.T) {
r, auth, logs := newTestRouter(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
+3
View File
@@ -36,7 +36,10 @@ 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)
+27 -6
View File
@@ -6,23 +6,44 @@ import (
"oci-portal/internal/service"
)
// registerAuthPublic 公开登录/OAuth 路由(不经 JWT)。
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, systemLogs *service.SystemLogService) {
// registerAuthPublic 公开登录/OAuth/通行密钥/钱包路由(不经 JWT)。
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, systemLogs *service.SystemLogService) {
ah := &authHandler{svc: auth, logs: systemLogs}
v1.POST("/auth/login", ah.login)
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
ax := &authxHandler{auth: auth, oauth: oauth, passkeys: passkeys, wallets: wallets, 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)
// 通行密钥登录:断言 options 公开下发,finish 内部接入登录守卫
pk := &passkeyHandler{svc: passkeys, auth: auth, logs: systemLogs}
v1.POST("/auth/passkey/login/begin", pk.loginBegin)
v1.POST("/auth/passkey/login/finish", pk.loginFinish)
// 钱包登录/绑定:挑战-签名两段式(bind 模式 handler 内校验 JWT)
wl := &walletHandler{svc: wallets, auth: auth, logs: systemLogs}
v1.POST("/auth/wallet/challenge", wl.challenge)
v1.POST("/auth/wallet/verify", wl.verify)
}
// registerAuthSecured 登出、两步验证、外部身份管理(JWT 组内)。
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, settings *service.SettingService, systemLogs *service.SystemLogService) {
// registerAuthSecured 登出、两步验证、外部身份与通行密钥管理(JWT 组内)。
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, settings *service.SettingService, systemLogs *service.SystemLogService) {
ah := &authHandler{svc: auth, logs: systemLogs}
secured.POST("/auth/logout", ah.logout)
pk := &passkeyHandler{svc: passkeys, auth: auth}
secured.POST("/auth/passkey/register/begin", pk.registerBegin)
secured.POST("/auth/passkey/register/finish", pk.registerFinish)
secured.GET("/auth/passkeys", pk.list)
secured.DELETE("/auth/passkeys/:id", pk.remove)
// 活跃会话:查看与定点撤销
sh := &sessionHandler{auth: auth}
secured.GET("/auth/sessions", sh.list)
secured.DELETE("/auth/sessions/:id", sh.revoke)
ax := &authxHandler{auth: auth, oauth: oauth}
secured.GET("/auth/totp", ax.totpStatus)
secured.POST("/auth/totp/setup", ax.totpSetup)
@@ -35,5 +56,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
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) })
}
+54
View File
@@ -16,6 +16,7 @@ func registerOci(secured *gin.RouterGroup, ociConfigs *service.OciConfigService)
registerOciCompute(secured, h)
registerOciNetwork(secured, h)
registerOciStorage(secured, h)
registerOciObjectStorage(secured, h)
registerOciCost(secured, h)
registerOciTenantIAM(secured, h)
}
@@ -37,6 +38,13 @@ func registerOciConfig(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/limits/services", h.limitServices)
g.GET("/oci-configs/:id/subscriptions", h.subscriptions)
g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
// 账单:发票与付款方式(OSP Gateway,仅主区域)
g.GET("/oci-configs/:id/invoices", h.invoices)
g.GET("/oci-configs/:id/invoices/:internalId/lines", h.invoiceLines)
g.GET("/oci-configs/:id/invoices/:internalId/pdf", h.invoicePdf)
g.POST("/oci-configs/:id/invoices/:internalId/pay", h.payInvoice)
g.GET("/oci-configs/:id/payment-methods", h.paymentMethods)
g.GET("/oci-configs/:id/shapes", h.shapes)
g.GET("/oci-configs/:id/images", h.images)
g.GET("/oci-configs/:id/images/:imageId", h.getImage)
@@ -61,6 +69,7 @@ func registerOciCompute(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
g.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6)
g.POST("/oci-configs/:id/vnics/:vnicId/change-public-ip", h.changeVnicPublicIP)
g.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic)
}
@@ -77,6 +86,10 @@ func registerOciNetwork(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
g.GET("/oci-configs/:id/reserved-ips", h.listReservedIPs)
g.POST("/oci-configs/:id/reserved-ips", h.createReservedIP)
g.PUT("/oci-configs/:id/reserved-ips/:publicIpId", h.assignReservedIP)
g.DELETE("/oci-configs/:id/reserved-ips/:publicIpId", h.deleteReservedIP)
g.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
g.POST("/oci-configs/:id/security-lists", h.createSecurityList)
g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
@@ -100,6 +113,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)
@@ -118,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)
@@ -133,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)
}
+3 -3
View File
@@ -7,11 +7,11 @@ import (
)
// registerSettings 挂载 /settings/* 与 /about,以及系统日志与代理管理。
func registerSettings(secured *gin.RouterGroup, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
func registerSettings(secured *gin.RouterGroup, auth *service.AuthService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
secured.GET("/about", about)
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
st := &settingsHandler{svc: settings, notifier: notifier}
st := &settingsHandler{svc: settings, auth: auth, notifier: notifier}
secured.GET("/settings/telegram", st.getTelegram)
secured.PUT("/settings/telegram", st.updateTelegram)
secured.POST("/settings/telegram/test", st.testTelegram)
@@ -26,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)
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"oci-portal/internal/service"
)
// sessionHandler 处理活跃会话查看与定点撤销接口。
type sessionHandler struct {
auth *service.AuthService
}
// bearerToken 取 Authorization 头中的原始令牌串;不存在返回空串。
func bearerToken(c *gin.Context) string {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
return ""
}
return token
}
// sessionMetaOf 采集会话登记所需的客户端上下文;登录方式由 service 层填写。
func sessionMetaOf(c *gin.Context) service.SessionMeta {
return service.SessionMeta{
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
}
}
// list 列出当前账号的活跃会话,最近活跃在前,当前会话带 current 标记。
//
// @Summary 活跃会话列表
// @Tags 认证
// @Success 200 {object} itemsResponse[service.SessionInfo] "items"
// @Security BearerAuth
// @Router /api/v1/auth/sessions [get]
func (h *sessionHandler) list(c *gin.Context) {
items, err := h.auth.ListSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// revoke 定点撤销一个其他会话;该会话令牌随即失效,不影响其余会话。
//
// @Summary 撤销单个会话
// @Tags 认证
// @Param id path int true "会话 ID"
// @Success 204 "已撤销"
// @Failure 400 {object} errorResponse "id 非法"
// @Failure 404 {object} errorResponse "会话不存在"
// @Failure 409 {object} errorResponse "不能撤销当前会话"
// @Security BearerAuth
// @Router /api/v1/auth/sessions/{id} [delete]
func (h *sessionHandler) revoke(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
err = h.auth.RevokeSession(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), uint(id))
if errors.Is(err, service.ErrSessionCurrent) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在或已失效"})
return
}
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+17 -6
View File
@@ -12,6 +12,7 @@ import (
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
type settingsHandler struct {
svc *service.SettingService
auth *service.AuthService
notifier *service.Notifier
}
@@ -316,25 +317,35 @@ 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 "请求体"
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
// @Success 200 {object} service.SecuritySettings
// @Failure 400 {object} errorResponse "字段越界或非法"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Failure 409 {object} errorResponse "密码登录禁用期间,面板地址变更将移除最后可用登录方式"
// @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
}
if err := h.svc.UpdateSecurity(c.Request.Context(), req); err != nil {
err := h.svc.UpdateSecurityAuthenticated(
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrInvalidSecurity) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrProviderLastLogin) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
+22
View File
@@ -15,6 +15,9 @@ import (
// errorResponse 是统一错误响应外壳。
type errorResponse struct {
Error string `json:"error"`
// Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429
// 返回 "RateLimited",前端据此与登录守卫的 429(无 code)区分
Code string `json:"code,omitempty"`
}
// itemsResponse 是 {"items": [...]} 列表外壳。
@@ -71,6 +74,20 @@ type credentialsResponse struct {
type oauthProvidersResponse struct {
Providers []service.ProviderInfo `json:"providers"`
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
PasskeyLogin bool `json:"passkeyLogin"`
WalletLogin bool `json:"walletLogin"`
}
// walletChallengeResponse 是钱包签名挑战响应;message 需原样 personal_sign。
type walletChallengeResponse struct {
Nonce string `json:"nonce"`
Message string `json:"message"`
}
// passkeyOptionsResponse 是 WebAuthn 仪式 options 响应;sessionId 需原样带回 finish。
type passkeyOptionsResponse struct {
SessionId string `json:"sessionId"`
Options json.RawMessage `json:"options"`
}
// urlResponse 是跳转地址响应。
@@ -157,6 +174,11 @@ 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"`
+16 -2
View File
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
// @Summary 更新任务
// @Tags 任务与日志回传
// @Param id path int true "任务 ID"
// @Param body body updateTaskRequest true "可更新字段"
// @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) {
@@ -113,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"
+105 -2
View File
@@ -38,6 +38,11 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
// @Summary 配置成本快照
// @Tags 成本
// @Param id path int true "配置 ID"
// @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]
@@ -68,13 +73,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
// ---- 租户审计日志 ----
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
//
// @Summary 批式懒加载查询租户 OCI 审计事件
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param cursor query string false "续查游标(上次响应原样带回)"
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
// @Success 200 {object} service.AuditEventsView
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/audit-events [get]
@@ -84,7 +91,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit, Q: c.Query("q")}
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
if errors.Is(err, service.ErrInvalidAuditCursor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -364,6 +371,102 @@ 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;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)"
// @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 {
UserID string `json:"userId"`
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.UserID, req.Fingerprint, req.PrivateKey); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---- 域通知收件人与密码策略 ----
// @Summary ---- 域通知收件人与密码策略 ----
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// walletHandler 处理钱包(SIWE)签名挑战与校验接口。
type walletHandler struct {
svc *service.WalletService
auth *service.AuthService
logs *service.SystemLogService
}
// walletChallengeRequest 是签名挑战请求;mode=bind 需有效 Bearer(绑定到当前账号)。
type walletChallengeRequest struct {
Address string `json:"address" binding:"required,max=64"`
Mode string `json:"mode" binding:"omitempty,oneof=login bind"`
}
// challenge 下发 EIP-4361 消息与一次性 nonce;前端对消息原样 personal_sign。
//
// @Summary 发起钱包签名挑战
// @Tags 认证
// @Param body body walletChallengeRequest true "钱包地址与模式(bind 需 Bearer)"
// @Success 200 {object} walletChallengeResponse "nonce 与待签名消息"
// @Failure 400 {object} errorResponse "地址格式不正确"
// @Failure 401 {object} errorResponse "bind 模式未登录"
// @Failure 409 {object} errorResponse "面板地址未设置"
// @Router /api/v1/auth/wallet/challenge [post]
func (h *walletHandler) challenge(c *gin.Context) {
var req walletChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode, username := "login", ""
if req.Mode == "bind" {
name, ok := (&authxHandler{auth: h.auth}).bearerUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
return
}
mode, username = "bind", name
}
nonce, message, err := h.svc.Challenge(c.Request.Context(), req.Address, mode, username, bearerToken(c))
if err != nil {
h.respondChallengeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"nonce": nonce, "message": message})
}
// respondChallengeErr 映射挑战阶段错误。
func (h *walletHandler) respondChallengeErr(c *gin.Context, err error) {
if errors.Is(err, service.ErrWalletAddress) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrWalletNoAppURL) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
}
// walletVerifyRequest 是签名校验请求;signature 为 65 字节 r||s||v 的 hex。
type walletVerifyRequest struct {
Nonce string `json:"nonce" binding:"required,max=64"`
Signature string `json:"signature" binding:"required,max=200"`
}
// verify 校验签名:login 模式签发 JWT,bind 模式绑定后换发新令牌。
//
// @Summary 校验钱包签名
// @Tags 认证
// @Param body body walletVerifyRequest true "nonce 与签名"
// @Success 200 {object} tokenResponse "token 与 expiresAt(bind 模式为换发的新 token)"
// @Failure 400 {object} errorResponse "挑战无效已过期,或 bind 模式签名校验失败"
// @Failure 401 {object} errorResponse "签名校验失败(login 模式)"
// @Failure 403 {object} errorResponse "地址未绑定账号"
// @Failure 409 {object} errorResponse "地址已绑定过"
// @Failure 429 {object} errorResponse "连续失败已锁定"
// @Router /api/v1/auth/wallet/verify [post]
func (h *walletHandler) verify(c *gin.Context) {
var req walletVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
start := time.Now()
res, err := h.svc.Verify(c.Request.Context(), req.Nonce, req.Signature, sessionMetaOf(c))
if err != nil {
status := walletErrStatus(err)
// bind 是已登录流程:验签失败不可回 401,否则前端全局拦截会误登出有效会话
if res.Mode == "bind" && status == http.StatusUnauthorized {
status = http.StatusBadRequest
}
h.recordWallet(c, res.Username, status, err, start)
c.JSON(status, gin.H{"error": walletErrText(err)})
return
}
h.recordWallet(c, res.Username, http.StatusOK, nil, start)
// login 与 bind 同构响应;bind 的 token 为版本递增后换发的新会话
c.JSON(http.StatusOK, gin.H{"token": res.Token, "expiresAt": res.ExpiresAt})
}
// walletErrStatus 映射校验阶段错误码。
func walletErrStatus(err error) int {
switch {
case errors.Is(err, service.ErrLoginLocked):
return http.StatusTooManyRequests
case errors.Is(err, service.ErrWalletChallenge):
return http.StatusBadRequest
case errors.Is(err, service.ErrWalletNotBound):
return http.StatusForbidden
case errors.Is(err, service.ErrWalletBound):
return http.StatusConflict
case errors.Is(err, service.ErrWalletSig):
return http.StatusUnauthorized
default:
return http.StatusInternalServerError
}
}
// walletErrText 把流程错误转为用户可读文案;内部错误不透出细节。
func walletErrText(err error) string {
for _, known := range []error{service.ErrLoginLocked, service.ErrWalletChallenge, service.ErrWalletNotBound, service.ErrWalletBound, service.ErrWalletSig} {
if errors.Is(err, known) {
return known.Error()
}
}
return "校验失败,请重试"
}
// recordWallet 记录钱包登录/绑定结果——公开端点不经系统日志中间件,须显式留痕。
func (h *walletHandler) recordWallet(c *gin.Context, username string, status int, err error, start time.Time) {
if h.logs == nil {
return
}
errMsg := ""
if err != nil {
errMsg = walletErrText(err)
}
h.logs.Record(model.SystemLog{
Username: username,
Method: http.MethodPost,
Path: requestPath(c),
Status: status,
DurationMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
ErrMsg: errMsg,
})
}
+1 -1
View File
@@ -64,7 +64,7 @@ func buildDialector(driver, dsn, path string) (gorm.Dialector, error) {
func autoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.User{}, &model.UserIdentity{}, &model.UserPasskey{}, &model.UserSession{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.CheckSnapshot{}, &model.CostSnapshot{},
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
+46 -2
View File
@@ -86,6 +86,45 @@ type UserIdentity struct {
CreatedAt time.Time `json:"createdAt"`
}
// UserPasskey 是账号绑定的 WebAuthn 通行密钥;公钥非敏感,凭据整段 JSON 明文落库。
type UserPasskey struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"-"`
Name string `gorm:"size:64" json:"name"`
// 凭据 ID(base64url)原文:规范允许原始 ID 达 1023 字节、编码后逾千字符,
// 存 text 不设索引;唯一约束与登录反查走下方定长哈希列
CredentialID string `gorm:"type:text" json:"-"`
// 凭据 ID 的 SHA-256 hex(定长 64):唯一索引防同一验证器重复注册
CredentialIDHash string `gorm:"size:64;uniqueIndex" json:"-"`
// 注册时的 WebAuthn origin(scheme://host):面板地址变更后按它判定
// 该凭据是否仍可登录(RP ID / origin 绑定注册时域名)
Origin string `gorm:"size:255" json:"-"`
// webauthn.Credential 完整 JSON(公钥/signCount/flags);attestation 可上 KB,
// 显式 text 防 MySQL DefaultStringSize 截断;校验后 signCount 有更新,整段重写
Credential string `gorm:"type:text" json:"-"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
// UserSession 是一次已签发登录会话(JWT)的落地记录;
// 「活跃」判定 = 未撤销 && 未过期 && token_ver == 账号当前版本。
// 升级前签发的存量令牌无对应行,校验时放行(兼容),仅不可见/不可定点撤销。
type UserSession struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"-"`
// JWT jti;敏感变更换发时同行更新(会话接续),不产生新条目
TokenID string `gorm:"size:64;uniqueIndex" json:"-"`
TokenVer uint `json:"-"`
// 登录方式:password / oidc / github / passkey / wallet;接续换发保留原值
Method string `gorm:"size:16" json:"method"`
ClientIP string `gorm:"size:64" json:"clientIp"`
UserAgent string `gorm:"size:256" json:"userAgent"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"-"`
}
// 任务类型取值。
const (
TaskTypeHealthCheck = "health_check" // 定时测活
@@ -229,6 +268,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"`
}
@@ -268,8 +308,12 @@ 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"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 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 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
+469 -184
View File
@@ -6,19 +6,29 @@ import (
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/audit"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
)
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
// 到限即返回 Truncated=true,由调用方收窄时间窗
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
// maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条,
// 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查
const maxAuditPages = 10
// auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时
// 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求,
// 前端按已回溯位置展示进度并自动续查。
const auditBatchTimeBudget = 20 * time.Second
// auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询
// 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。
const auditSearchPageLimit = 200
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存,
// 列表响应不再携带(详情接口按 eventId 取回)。
type AuditEvent struct {
EventId string `json:"eventId"`
@@ -35,7 +45,7 @@ type AuditEvent struct {
Raw json.RawMessage `json:"raw,omitempty"`
}
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断,
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
type AuditEventsResult struct {
Items []AuditEvent `json:"items"`
@@ -43,6 +53,324 @@ type AuditEventsResult struct {
NextPage string `json:"nextPage,omitempty"`
}
// auditSearchClient 构造区域化的日志搜索客户端。审计数据源为 Logging Search
// 的 _Audit 日志:Audit API 无排序参数、窗口内固定按处理时间正序,首批只能
// 拿到窗口内最旧的一段;Logging Search 支持 datetime 倒序,才能从最新回溯。
func (c *RealClient) auditSearchClient(cred Credentials, region string) (loggingsearch.LogSearchClient, error) {
sc, err := loggingsearch.NewLogSearchClientWithConfigurationProvider(provider(cred))
if err != nil {
return sc, fmt.Errorf("new logging search client: %w", err)
}
applyProxy(&sc.BaseClient, cred)
if region != "" {
sc.SetRegion(normalizeRegion(region))
}
return sc, nil
}
// auditSearchQuery 组装租户根 compartment 审计日志的倒序检索语句;
// SummarizeMetricsData 遥测噪声占比高,服务端先滤一道减少无效翻页。
// q 非空时追加 logContent 全文包含匹配——它扫的是整条 JSON 的所有值,只当
// 粗筛;可见字段的精筛由 filterAuditTerm 兜底,避免隐藏元数据误命中。
func auditSearchQuery(tenancyOCID, q string) string {
query := fmt.Sprintf("search %q | where data.eventName != 'SummarizeMetricsData'", tenancyOCID+"/_Audit")
if term := SanitizeAuditTerm(q); term != "" {
query += fmt.Sprintf(" and logContent = '*%s*'", term)
}
return query + " | sort by datetime desc"
}
// auditTermMaxLen 限制检索关键字长度,防止游标与查询语句被撑爆。
const auditTermMaxLen = 100
// SanitizeAuditTerm 归一检索关键字:去除引号/反斜杠/控制字符防语句注入
// (查询目标已锁定本租户 _Audit 流,注入最坏只是语法错),截断超长输入;
// 保留 * 供用户通配。返回空串表示不追加过滤子句。
// service 构造首查游标与本包组装语句共用,对篡改游标二次消毒兜底。
func SanitizeAuditTerm(q string) string {
out := make([]rune, 0, len(q))
for _, r := range q {
if r == '\'' || r == '"' || r == '\\' || r < 0x20 {
continue
}
out = append(out, r)
if len(out) >= auditTermMaxLen {
break
}
}
return strings.TrimSpace(string(out))
}
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
// Search 配额为零的租户自动回退 Audit API 重查同一窗口(page 跨通道失效,重头吃窗)。
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
f := c.newAuditFetchers(cred, region)
res, err := listAuditWindow(ctx, f.search, AuditCursor{Start: start, End: end, Page: page})
if err != nil && isSearchQuotaZero(err) {
res, err = listAuditWindow(ctx, f.audit, AuditCursor{Start: start, End: end})
}
return res, err
}
// listAuditWindow 用给定取页函数吃一个固定时间窗,最多 maxAuditPages 页;
// 页预算耗尽即截断,NextPage 携带未消费的窗内游标。
func listAuditWindow(ctx context.Context, fetch auditPageFetch, cur AuditCursor) (AuditEventsResult, error) {
result := AuditEventsResult{Items: []AuditEvent{}}
for i := 0; i < maxAuditPages; i++ {
items, next, err := fetch(ctx, cur)
if err != nil {
return AuditEventsResult{}, err
}
result.Items = appendKeptAuditEvents(result.Items, items)
if next == "" {
sortAuditEvents(result.Items)
return result, nil
}
cur.Page = next
}
result.Truncated = true
result.NextPage = cur.Page
sortAuditEvents(result.Items)
return result, nil
}
// auditPageFetch 拉取游标位置的一页已映射事件,返回窗内下一页游标。
type auditPageFetch func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error)
// auditFetchers 汇集两条数据通道:search 为 Logging Search 倒序主路,
// audit 为 Search 配额为零租户的 Audit API 回退路。
type auditFetchers struct {
search auditPageFetch
audit auditPageFetch
}
// newAuditFetchers 构造两条通道的取页闭包;客户端惰性初始化,
// 各模式的续查不会白建用不到的客户端。
func (c *RealClient) newAuditFetchers(cred Credentials, region string) auditFetchers {
return auditFetchers{search: c.searchFetcher(cred, region), audit: c.auditAPIFetcher(cred, region)}
}
// searchFetcher 构造 Logging Search 通道的取页闭包。
func (c *RealClient) searchFetcher(cred Credentials, region string) auditPageFetch {
var sc *loggingsearch.LogSearchClient
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
if sc == nil {
cli, err := c.auditSearchClient(cred, region)
if err != nil {
return nil, "", err
}
sc = &cli
}
return searchAuditPage(ctx, *sc, cred.TenancyOCID, cur)
}
}
// auditAPIFetcher 构造 Audit API 回退通道的取页闭包。
func (c *RealClient) auditAPIFetcher(cred Credentials, region string) auditPageFetch {
var ac *audit.AuditClient
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
if ac == nil {
cli, err := c.auditClient(cred, region)
if err != nil {
return nil, "", err
}
ac = &cli
}
return listAuditPage(ctx, *ac, cred.TenancyOCID, cur)
}
}
// isSearchQuotaZero 识别「租户 Logging Search 配额为零」的失败:此类租户该
// 服务永久不可用(maxQueriesPerMinute/maxConcurrentQueries 均为 0),应回退
// Audit API;普通限流(配额非零)不回退,避免数据通道来回切换。
func isSearchQuotaZero(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.ReplaceAll(err.Error(), " ", ""))
return strings.Contains(msg, "ratelimitexceeded") && strings.Contains(msg, "maxqueriesperminute:0,")
}
// appendKeptAuditEvents 过滤噪声后追加一页已映射事件;窗口式与批式查询共用。
func appendKeptAuditEvents(dst []AuditEvent, items []AuditEvent) []AuditEvent {
for _, ev := range items {
if keepAuditEvent(ev) {
dst = append(dst, ev)
}
}
return dst
}
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
// 批式查询参数:单批翻页预算沿用 maxAuditPages;首窗 24h,连续空窗倍增
// 加速跨越闲置期,上限 14 天(Logging Search 单次查询时间窗硬限);
// 回溯下限为审计事件保留期 365 天。
const (
auditWindowHours = 24
auditWindowMaxHours = 336
auditRetentionDays = 365
)
// auditModeFallback 标记游标处于 Audit API 回退模式:部分租户的
// Logging Search 服务配额为零(maxQueriesPerMinute: 0),永久不可用。
const auditModeFallback = "a"
// auditFallbackWindowHours 是回退模式的基准窗宽:Audit API 窗口内固定按
// 处理时间正序且无排序参数,只能小窗回溯 + 前端全局重排保住从新到旧的体验。
const auditFallbackWindowHours = 1
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内翻页游标、当前窗宽
// (小时,空窗倍增的记忆)、检索关键字(随游标续查,保证跨批过滤一致)
// 与数据通道模式(空为 Search 主路,"a" 为 Audit API 回退,续查沿用不再试错)。
// 序列化为不透明 cursor 由 service 层负责。
type AuditCursor struct {
Start time.Time `json:"s"`
End time.Time `json:"e"`
Page string `json:"p,omitempty"`
WindowHours int `json:"w"`
Q string `json:"q,omitempty"`
M string `json:"m,omitempty"`
}
// toFallback 把游标切到 Audit API 回退模式:Search 页游标跨通道失效须清空;
// 首窗收窄到基准窗宽,避免大窗正序分页又回到「首批全是窗口内最旧事件」的老问题。
func (cur AuditCursor) toFallback() AuditCursor {
cur.M = auditModeFallback
cur.Page = ""
cur.WindowHours = auditFallbackWindowHours
if cur.End.Sub(cur.Start) > auditFallbackWindowHours*time.Hour {
cur.Start = cur.End.Add(-auditFallbackWindowHours * time.Hour)
}
return cur
}
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
func NewAuditCursor(now time.Time) AuditCursor {
end := now.UTC().Truncate(time.Minute)
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
}
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
// 否则重置为该模式基准窗宽。done 为 true 表示已越过保留期尽头。
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
base := auditWindowHours
if cur.M == auditModeFallback {
base = auditFallbackWindowHours
}
w := cur.WindowHours
if w <= 0 {
w = base
}
if empty {
if w *= 2; w > auditWindowMaxHours {
w = auditWindowMaxHours
}
} else {
w = base
}
end := cur.Start
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
return cur, true
}
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w, Q: cur.Q, M: cur.M}, false
}
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
// 表示已回溯到保留期尽头,无更早数据。
type AuditBatchResult struct {
Items []AuditEvent
Cursor *AuditCursor
Exhausted bool
}
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
// 保留事件;单批受页预算与时间预算双重约束,不足额也返回,由前端按需续查。
// 倒序返回下,窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
return listAuditBatch(ctx, c.newAuditFetchers(cred, region), cur, limit)
}
// listAuditBatch 是批式回溯的通道无关内核,取页函数注入便于测试。
func listAuditBatch(ctx context.Context, f auditFetchers, cur AuditCursor, limit int) (AuditBatchResult, error) {
res := AuditBatchResult{Items: []AuditEvent{}}
windowHasKept := false
deadline := time.Now().Add(auditBatchTimeBudget)
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit && time.Now().Before(deadline); budget-- {
items, next, nextCur, err := fetchAuditPage(ctx, f, cur)
if err != nil {
return AuditBatchResult{}, err
}
cur = nextCur
before := len(res.Items)
res.Items = appendKeptAuditEvents(res.Items, filterAuditTerm(items, cur))
windowHasKept = windowHasKept || len(res.Items) > before
if next != "" {
cur.Page = next
continue
}
adv, done := cur.advance(time.Now(), !windowHasKept)
if done {
res.Exhausted = true
sortAuditEvents(res.Items)
return res, nil
}
cur, windowHasKept = adv, false
}
sortAuditEvents(res.Items)
res.Cursor = &cur
return res, nil
}
// fetchAuditPage 按游标模式取一页;Search 主路报「配额为零」时切到回退游标
// 并立即用 Audit API 重试,后续批次凭游标模式直达回退通道不再试错。
func fetchAuditPage(ctx context.Context, f auditFetchers, cur AuditCursor) ([]AuditEvent, string, AuditCursor, error) {
if cur.M == auditModeFallback {
items, next, err := f.audit(ctx, cur)
return items, next, cur, err
}
items, next, err := f.search(ctx, cur)
if err != nil && isSearchQuotaZero(err) {
cur = cur.toFallback()
items, next, err = f.audit(ctx, cur)
}
return items, next, cur, err
}
// filterAuditTerm 关键字精筛:只认列表可见字段(matchesAuditTerm),两条通道
// 语义一致。Search 主路的 logContent 全文条件会命中隐藏认证元数据(如
// opc-principal 头里的 ttype:login),只作粗筛减少翻页,不作为最终判定。
func filterAuditTerm(items []AuditEvent, cur AuditCursor) []AuditEvent {
if cur.Q == "" {
return items
}
out := items[:0]
for _, ev := range items {
if matchesAuditTerm(ev, cur.Q) {
out = append(out, ev)
}
}
return out
}
// matchesAuditTerm 判断事件是否命中关键字:不区分大小写的包含匹配,
// * 作为通配分段、各段都出现即命中,近似 Search 通道的 logContent 语义。
func matchesAuditTerm(ev AuditEvent, q string) bool {
hay := strings.ToLower(strings.Join([]string{
ev.EventName, ev.Source, ev.ResourceName, ev.CompartmentName,
ev.PrincipalName, ev.IPAddress, ev.Status, ev.RequestAction, ev.RequestPath,
}, "\n"))
for _, part := range strings.Split(strings.ToLower(q), "*") {
if part != "" && !strings.Contains(hay, part) {
return false
}
}
return true
}
// auditClient 构造区域化的 Audit API 客户端(回退通道)。
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
if err != nil {
@@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl
return ac, nil
}
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
ac, err := c.auditClient(cred, region)
if err != nil {
return AuditEventsResult{}, err
}
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
req := audit.ListEventsRequest{
CompartmentId: &cred.TenancyOCID,
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
}
if page != "" {
req.Page = &page
}
result := AuditEventsResult{Items: []AuditEvent{}}
for i := 0; i < maxAuditPages; i++ {
resp, err := ac.ListEvents(ctx, req)
if err != nil {
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
}
appendAuditEvents(&result, resp.Items)
if resp.OpcNextPage == nil {
sortAuditEvents(result.Items)
return result, nil
}
req.Page = resp.OpcNextPage
}
result.Truncated = true
result.NextPage = deref(req.Page)
sortAuditEvents(result.Items)
return result, nil
}
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
result.Items = appendKeptAuditEvents(result.Items, items)
}
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
for _, ev := range items {
out := toAuditEvent(ev)
if !keepAuditEvent(out) {
continue
}
if raw, mErr := json.Marshal(ev); mErr == nil {
out.Raw = raw
}
dst = append(dst, out)
}
return dst
}
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
const (
auditWindowHours = 24
auditWindowMaxHours = 720
auditRetentionDays = 365
)
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
type AuditCursor struct {
Start time.Time `json:"s"`
End time.Time `json:"e"`
Page string `json:"p,omitempty"`
WindowHours int `json:"w"`
}
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
func NewAuditCursor(now time.Time) AuditCursor {
end := now.UTC().Truncate(time.Minute)
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
}
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
w := cur.WindowHours
if w <= 0 {
w = auditWindowHours
}
if empty {
if w *= 2; w > auditWindowMaxHours {
w = auditWindowMaxHours
}
} else {
w = auditWindowHours
}
end := cur.Start
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
return cur, true
}
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
}
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
// 表示已回溯到保留期尽头,无更早数据。
type AuditBatchResult struct {
Items []AuditEvent
Cursor *AuditCursor
Exhausted bool
}
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
ac, err := c.auditClient(cred, region)
if err != nil {
return AuditBatchResult{}, err
}
res := AuditBatchResult{Items: []AuditEvent{}}
windowHasKept := false
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
if err != nil {
return AuditBatchResult{}, err
}
before := len(res.Items)
res.Items = appendKeptAuditEvents(res.Items, items)
windowHasKept = windowHasKept || len(res.Items) > before
if next != "" {
cur.Page = next
continue
}
nextCur, done := cur.advance(time.Now(), !windowHasKept)
if done {
res.Exhausted = true
sortAuditEvents(res.Items)
return res, nil
}
cur, windowHasKept = nextCur, false
}
sortAuditEvents(res.Items)
res.Cursor = &cur
return res, nil
}
// listAuditPage 拉取当前游标位置的一页原始事件。
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
// listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定
// 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文,
// 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
req := audit.ListEventsRequest{
CompartmentId: &tenancyOCID,
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
@@ -214,42 +399,18 @@ func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string
if err != nil {
return nil, "", fmt.Errorf("list audit events: %w", err)
}
return resp.Items, deref(resp.OpcNextPage), nil
}
// 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
items := make([]AuditEvent, 0, len(resp.Items))
for _, ev := range resp.Items {
out := toAuditEvent(ev)
if raw, mErr := json.Marshal(ev); mErr == nil {
out.Raw = raw
}
items = append(items, out)
}
return true
return items, deref(resp.OpcNextPage), nil
}
// toAuditEvent 把 SDK 审计事件压平为列表 DTOSDK 字段全为指针逐层判 nil。
// toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
if ev.EventTime != nil {
@@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent {
return out
}
// searchAuditPage 拉取游标窗口内按 datetime 倒序的一页审计事件(已映射未过滤)。
func searchAuditPage(ctx context.Context, sc loggingsearch.LogSearchClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
req := loggingsearch.SearchLogsRequest{
SearchLogsDetails: loggingsearch.SearchLogsDetails{
TimeStart: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
TimeEnd: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
SearchQuery: common.String(auditSearchQuery(tenancyOCID, cur.Q)),
},
Limit: common.Int(auditSearchPageLimit),
}
if cur.Page != "" {
req.Page = &cur.Page
}
resp, err := sc.SearchLogs(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("search audit logs: %w", err)
}
items := make([]AuditEvent, 0, len(resp.Results))
for _, r := range resp.Results {
if ev, ok := toSearchAuditEvent(r); ok {
items = append(items, ev)
}
}
return items, deref(resp.OpcNextPage), nil
}
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
var auditInternalCIDRs = func() []*net.IPNet {
out := make([]*net.IPNet, 0, 4)
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
_, block, _ := net.ParseCIDR(cidr)
out = append(out, block)
}
return out
}()
// keepAuditEvent 保留有展示价值的事件:SummarizeMetricsData 已在检索语句里
// 先滤(此处兜底),内网地址发起的服务互调用 CIDR 判断(查询语言不便表达);
// 无 IP 的事件(控制面内部)保留。
func keepAuditEvent(ev AuditEvent) bool {
if ev.EventName == "SummarizeMetricsData" {
return false
}
if ev.IPAddress == "" {
return true
}
ip := net.ParseIP(ev.IPAddress)
if ip == nil {
return true
}
for _, block := range auditInternalCIDRs {
if block.Contains(ip) {
return false
}
}
return true
}
// searchAuditContent 是 _Audit 日志 logContent 的字段投影,只取列表展示所需;
// identity / request / response 可能为 null,零值即缺省。
type searchAuditContent struct {
ID string `json:"id"`
Time *time.Time `json:"time"`
Source string `json:"source"`
Data struct {
EventName string `json:"eventName"`
ResourceName string `json:"resourceName"`
CompartmentName string `json:"compartmentName"`
Identity struct {
PrincipalName string `json:"principalName"`
IPAddress string `json:"ipAddress"`
} `json:"identity"`
Request struct {
Action string `json:"action"`
Path string `json:"path"`
} `json:"request"`
Response struct {
Status string `json:"status"`
} `json:"response"`
} `json:"data"`
}
// toSearchAuditEvent 把日志搜索结果压平为列表 DTO;Raw 即 logContent 原文。
// 结构不符的条目丢弃(返回 false),不因单条脏数据整页失败。
func toSearchAuditEvent(r loggingsearch.SearchResult) (AuditEvent, bool) {
if r.Data == nil {
return AuditEvent{}, false
}
b, err := json.Marshal(r.Data)
if err != nil {
return AuditEvent{}, false
}
var hit struct {
LogContent json.RawMessage `json:"logContent"`
}
if err := json.Unmarshal(b, &hit); err != nil || len(hit.LogContent) == 0 {
return AuditEvent{}, false
}
var content searchAuditContent
if err := json.Unmarshal(hit.LogContent, &content); err != nil {
return AuditEvent{}, false
}
ev := searchContentToEvent(content)
ev.Raw = hit.LogContent
return ev, true
}
// searchContentToEvent 把投影字段填入列表 DTO;Raw 由调用方设置。
func searchContentToEvent(c searchAuditContent) AuditEvent {
return AuditEvent{
EventId: c.ID,
EventTime: c.Time,
EventName: c.Data.EventName,
Source: c.Source,
ResourceName: c.Data.ResourceName,
CompartmentName: c.Data.CompartmentName,
PrincipalName: c.Data.Identity.PrincipalName,
IPAddress: c.Data.Identity.IPAddress,
Status: c.Data.Response.Status,
RequestAction: c.Data.Request.Action,
RequestPath: c.Data.Request.Path,
}
}
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
func sortAuditEvents(items []AuditEvent) {
sort.SliceStable(items, func(i, j int) bool {
+294 -27
View File
@@ -1,14 +1,262 @@
package oci
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/audit"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
)
// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。
var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`)
func TestIsSearchQuotaZero(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"配额为零真实报错", quotaZeroErr, true},
{"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false},
{"其他错误", errors.New("service unavailable"), false},
{"nil", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isSearchQuotaZero(tc.err); got != tc.want {
t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want)
}
})
}
}
func TestListAuditBatchFallback(t *testing.T) {
et := time.Now().UTC().Add(-10 * time.Minute)
searchCalls, auditCalls := 0, 0
f := auditFetchers{
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
searchCalls++
return nil, "", quotaZeroErr
},
audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
auditCalls++
if cur.M != auditModeFallback || cur.Page != "" {
t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur)
}
ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et}
return []AuditEvent{ev}, "", nil
},
}
res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3)
if err != nil {
t.Fatalf("配额为零应回退成功, got %v", err)
}
if searchCalls != 1 {
t.Fatalf("Search 只应试错一次, got %d", searchCalls)
}
if len(res.Items) < 3 || auditCalls < 3 {
t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls)
}
if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours {
t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor)
}
}
func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) {
et := time.Now().UTC().Add(-10 * time.Minute)
f := auditFetchers{
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
t.Fatal("回退模式游标不应再调用 Search 通道")
return nil, "", nil
},
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil
},
}
cur := NewAuditCursor(time.Now()).toFallback()
if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil {
t.Fatalf("回退模式续查失败: %v", err)
}
}
func TestListAuditBatchSearchErrorNoFallback(t *testing.T) {
f := auditFetchers{
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
return nil, "", errors.New("search audit logs: timeout")
},
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
t.Fatal("普通错误不应触发回退")
return nil, "", nil
},
}
if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil {
t.Fatal("普通错误应原样上抛")
}
}
func TestFilterAuditTerm(t *testing.T) {
login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"}
noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"}
items := []AuditEvent{login, noise}
cases := []struct {
name string
cur AuditCursor
want int
}{
{"无关键字原样放行", AuditCursor{}, 2},
{"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1},
{"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want {
t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want)
}
})
}
}
func TestListAuditBatchSearchTermPrecision(t *testing.T) {
et := time.Now().UTC().Add(-10 * time.Minute)
// 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login)
f := auditFetchers{
search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
return []AuditEvent{
{EventId: "hit", EventName: "InteractiveLogin", EventTime: &et},
{EventId: "noise1", EventName: "ListRecommendations", EventTime: &et},
{EventId: "noise2", EventName: "SearchLogs", EventTime: &et},
}, "", nil
},
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
t.Fatal("Search 正常时不应走回退")
return nil, "", nil
},
}
cur := NewAuditCursor(time.Now())
cur.Q = "login"
res, err := listAuditBatch(context.Background(), f, cur, 1)
if err != nil {
t.Fatalf("listAuditBatch() err = %v", err)
}
if len(res.Items) != 1 || res.Items[0].EventId != "hit" {
t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items)
}
}
func TestMatchesAuditTerm(t *testing.T) {
ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"}
cases := []struct {
name string
q string
want bool
}{
{"不区分大小写", "listvnic", true},
{"通配分段都出现", "List*Attachments", true},
{"资源名命中", "WEB-1", true},
{"未命中", "TerminateInstance", false},
{"通配缺段不命中", "List*Volume", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := matchesAuditTerm(ev, tc.q); got != tc.want {
t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want)
}
})
}
}
// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。
func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult {
t.Helper()
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("fixture 不是合法 JSON: %v", err)
}
return loggingsearch.SearchResult{Data: &v}
}
func TestToSearchAuditEvent(t *testing.T) {
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
tests := []struct {
name string
data string
wantOK bool
want AuditEvent
}{
{
name: "全字段齐全",
data: `{"datetime":1783074600000,"logContent":{
"id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi",
"data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod",
"identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"},
"request":{"action":"DELETE","path":"/20160918/instances/ocid1..."},
"response":{"status":"204"}}}}`,
wantOK: true,
want: AuditEvent{
EventId: "evt-abc",
EventTime: &eventTime,
EventName: "TerminateInstance",
Source: "ComputeApi",
ResourceName: "web-1",
CompartmentName: "prod",
PrincipalName: "api-admin",
IPAddress: "1.2.3.4",
Status: "204",
RequestAction: "DELETE",
RequestPath: "/20160918/instances/ocid1...",
},
},
{
name: "identity/request/response 为 null 时只保留信封字段",
data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi",
"data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`,
wantOK: true,
want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"},
},
{
name: "缺 logContent 丢弃",
data: `{"datetime":1783074600000}`,
wantOK: false,
},
{
name: "logContent 结构不符丢弃",
data: `{"logContent":"plain-text"}`,
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data))
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if !ok {
return
}
if len(got.Raw) == 0 {
t.Fatalf("Raw 应携带 logContent 原文")
}
if !auditEventEqual(got, tt.want) {
t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestToSearchAuditEventNilData(t *testing.T) {
if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok {
t.Fatal("Data 为 nil 应丢弃")
}
}
func TestToAuditEvent(t *testing.T) {
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
tests := []struct {
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
},
},
want: AuditEvent{
EventId: "evt-abc",
EventTime: &eventTime,
EventName: "TerminateInstance",
Source: "ComputeApi",
ResourceName: "web-1",
CompartmentName: "prod",
PrincipalName: "api-admin",
IPAddress: "1.2.3.4",
Status: "204",
RequestAction: "DELETE",
RequestPath: "/20160918/instances/ocid1...",
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
},
},
{
name: "Data 为 nil 时只保留信封字段",
ev: audit.AuditEvent{
Source: common.String("VcnApi"),
EventTime: &common.SDKTime{Time: eventTime},
},
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
},
{
name: "嵌套局部 nil 各自安全跳过",
ev: audit.AuditEvent{
Data: &audit.Data{
EventName: common.String("GetInstance"),
Identity: nil,
Request: &audit.Request{Path: common.String("/instances")},
Response: nil,
},
},
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
},
{
name: "空事件全部零值",
ev: audit.AuditEvent{},
want: AuditEvent{},
},
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) {
}
}
func TestAuditSearchQuery(t *testing.T) {
const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'`
cases := []struct {
name string
term string
want string
}{
{"无关键字", "", prefix + ` | sort by datetime desc`},
{"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`},
{"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`},
{"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want {
t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want)
}
})
}
}
func TestSanitizeAuditTerm(t *testing.T) {
if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" {
t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got)
}
long := strings.Repeat("a", 300)
if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen {
t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got))
}
}
// auditEventEqual 比较两个 DTOEventTime 按值比较,Raw 不参与,其余反射比较。
func auditEventEqual(a, b AuditEvent) bool {
if (a.EventTime == nil) != (b.EventTime == nil) {
@@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) {
Start: now.Add(-24 * time.Hour),
End: now,
WindowHours: 24,
Q: "kw",
}
cases := []struct {
name string
@@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) {
}{
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
{"空窗倍增", base, true, 48, false},
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
{"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false},
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
{"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false},
{"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false},
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
}
for _, tc := range cases {
@@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) {
if next.Page != "" {
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
}
if next.Q != tc.cur.Q {
t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q)
}
if next.M != tc.cur.M {
t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M)
}
})
}
}
+320
View File
@@ -0,0 +1,320 @@
package oci
import (
"context"
"fmt"
"io"
"math"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/ospgateway"
)
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
type Invoice struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
Type string `json:"type"`
Status string `json:"status"`
IsPaid bool `json:"isPaid"`
IsPayable bool `json:"isPayable"`
IsPaymentFailed bool `json:"isPaymentFailed"`
Amount float64 `json:"amount"`
AmountDue float64 `json:"amountDue"`
Currency string `json:"currency"`
TimeInvoice *time.Time `json:"timeInvoice"`
TimeDue *time.Time `json:"timeDue"`
}
// InvoiceLine 是发票内一行费用明细。
type InvoiceLine struct {
Product string `json:"product"`
OrderNo string `json:"orderNo"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
Total float64 `json:"total"`
Currency string `json:"currency"`
TimeStart *time.Time `json:"timeStart"`
TimeEnd *time.Time `json:"timeEnd"`
}
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
type PaymentMethod struct {
Method string `json:"method"` // CREDIT_CARD / PAYPAL
CardType string `json:"cardType,omitempty"`
LastDigits string `json:"lastDigits,omitempty"`
NameOnCard string `json:"nameOnCard,omitempty"`
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
Email string `json:"email,omitempty"`
PayerName string `json:"payerName,omitempty"`
BillingAgreement string `json:"billingAgreement,omitempty"`
}
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
info, err := c.ValidateKey(ctx, cred)
if err != nil {
return "", fmt.Errorf("resolve osp home region: %w", err)
}
r, ok := RegionByKey(info.HomeRegionKey)
if !ok {
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
}
if cred.TenancyOCID != "" {
c.homeRegions.Store(cred.TenancyOCID, r.Name)
}
return r.Name, nil
}
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", err
}
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
}
applyProxy(&ic.BaseClient, cred)
ic.SetRegion(region)
return ic, region, nil
}
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
// (按 timeInvoice 窗口过滤),为 0 拉全量。
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
req := ospgateway.ListInvoicesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Limit: common.Int(ospPageLimit),
}
if year > 0 {
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
}
var out []Invoice
for {
resp, err := ic.ListInvoices(ctx, req)
if err != nil {
return nil, fmt.Errorf("list invoices: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoice(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
req.Page = resp.OpcNextPage
}
}
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
const ospPageLimit = 100
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
var out []InvoiceLine
var page *string
for {
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
Page: page,
Limit: common.Int(ospPageLimit),
})
if err != nil {
return nil, fmt.Errorf("list invoice lines: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoiceLine(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
})
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
}
return data, nil
}
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return err
}
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
})
if err != nil {
return fmt.Errorf("pay invoice: %w", err)
}
return nil
}
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return nil, err
}
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return nil, fmt.Errorf("new osp subscription client: %w", err)
}
applyProxy(&sc.BaseClient, cred)
sc.SetRegion(region)
var out []PaymentMethod
var page *string
for {
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
for _, sub := range resp.Items {
out = appendPaymentMethods(out, sub.PaymentOptions)
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
inv := Invoice{
ID: deref(v.InvoiceId),
InternalID: deref(v.InternalInvoiceId),
Number: deref(v.InvoiceNumber),
Type: string(v.InvoiceType),
Status: string(v.InvoiceStatus),
Amount: money(v.InvoiceAmount),
AmountDue: money(v.InvoiceAmountDue),
TimeInvoice: sdkTime(v.TimeInvoice),
TimeDue: sdkTime(v.TimeInvoiceDue),
}
if v.IsPaid != nil {
inv.IsPaid = *v.IsPaid
}
if v.IsPayable != nil {
inv.IsPayable = *v.IsPayable
}
if v.IsPaymentFailed != nil {
inv.IsPaymentFailed = *v.IsPaymentFailed
}
if v.Currency != nil {
inv.Currency = deref(v.Currency.CurrencyCode)
}
return inv
}
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
line := InvoiceLine{
Product: deref(v.Product),
OrderNo: deref(v.OrderNo),
Quantity: money(v.Quantity),
UnitPrice: money(v.NetUnitPrice),
Total: money(v.TotalPrice),
TimeStart: sdkTime(v.TimeStart),
TimeEnd: sdkTime(v.TimeEnd),
}
if v.Currency != nil {
line.Currency = deref(v.Currency.CurrencyCode)
}
return line
}
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
for _, opt := range opts {
switch v := opt.(type) {
case ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(v))
case *ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(*v))
case ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(v))
case *ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(*v))
}
}
return out
}
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "CREDIT_CARD",
CardType: string(v.CreditCardType),
LastDigits: deref(v.LastDigits),
NameOnCard: deref(v.NameOnCard),
TimeExpiration: sdkTime(v.TimeExpiration),
}
}
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "PAYPAL",
Email: deref(v.EmailAddress),
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
BillingAgreement: deref(v.ExtBillingAgreementId),
}
}
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
func money(p *float32) float64 {
if p == nil {
return 0
}
return math.Round(float64(*p)*10000) / 10000
}
+3 -1
View File
@@ -32,8 +32,10 @@ func NewCachedClient(inner Client) *CachedClient {
}
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
// compartment 必须参与键值:列表查询按 EffectiveCompartment 过滤,
// 同租户切换区间时若共用键会串到上一个区间的缓存结果。
func ckey(cred Credentials, parts ...string) string {
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
return cred.TenancyOCID + "|" + cred.CompartmentID + "|" + strings.Join(parts, "|")
}
// bust 写操作成功后失效该租户全部读缓存。
+7
View File
@@ -49,6 +49,13 @@ func TestCachedClientHitAndIsolation(t *testing.T) {
if inner.instCalls != 3 {
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
}
// 同租户不同 compartment 各自回源,不得共用缓存
inCompartment := testCred("t1")
inCompartment.CompartmentID = "ocid1.compartment.a"
_, _ = c.ListInstances(ctx, inCompartment, "r1")
if inner.instCalls != 4 {
t.Errorf("跨 compartment 回源 %d 次, want 4", inner.instCalls)
}
}
func TestCachedClientWriteBusts(t *testing.T) {
+55 -6
View File
@@ -50,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 列出可用的实例镜像。
@@ -91,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。
@@ -102,10 +139,12 @@ type Client interface {
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, 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 服务端工具通路)
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, 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 文档重排,返回按相关度排序的下标与得分。
@@ -150,6 +189,10 @@ type Client interface {
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, domainID string) (NotificationRecipients, error)
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
@@ -163,6 +206,10 @@ type Client interface {
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
@@ -182,8 +229,10 @@ type Client interface {
// RealClient 基于官方 SDK 实现 Client。
type RealClient struct {
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
shapes sync.Map // tenancy|region|ad → shapeCacheEntryshape 清单缓存
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
shapes sync.Map // tenancy|region|ad → shapeCacheEntryshape 清单缓存
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
}
// NewClient 返回生产使用的真实客户端。
+38 -13
View File
@@ -14,15 +14,16 @@ import (
type CostQuery struct {
StartTime time.Time
EndTime time.Time
Granularity string // DAILY / MONTHLY
Granularity string // HOURLY / DAILY / MONTHLY
QueryType string // COST / USAGE
GroupBy string // service / skuName / region 等维度
GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子)
}
// CostItem 是一个时间桶内某分组维度的成本或用量。
type CostItem struct {
TimeStart *time.Time `json:"timeStart"`
GroupValue string `json:"groupValue"`
SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName)
ComputedAmount float32 `json:"computedAmount"`
ComputedQuantity float32 `json:"computedQuantity"`
Currency string `json:"currency"`
@@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
Granularity: granularity,
QueryType: queryType,
GroupBy: []string{q.GroupBy},
GroupBy: splitGroupBy(q.GroupBy),
}, nil
}
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段
// splitGroupBy 把 "service,skuName" 复合维度拆成 Usage API 的 groupBy 列表
func splitGroupBy(groupBy string) []string {
parts := strings.Split(groupBy, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// dimValue 按维度名取响应条目上的分组值。
func dimValue(item usageapi.UsageSummary, dim string) string {
switch strings.ToLower(strings.TrimSpace(dim)) {
case "skuname":
return deref(item.SkuName)
case "region":
return deref(item.Region)
case "compartmentname":
return deref(item.CompartmentName)
default:
return deref(item.Service)
}
}
// toCostItem 把响应条目转为本地 DTO;首维进 GroupValue,复合分组的次维进 SubValue。
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
out := CostItem{
Currency: deref(item.Currency),
@@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
if item.ComputedQuantity != nil {
out.ComputedQuantity = *item.ComputedQuantity
}
switch strings.ToLower(groupBy) {
case "skuname":
out.GroupValue = deref(item.SkuName)
case "region":
out.GroupValue = deref(item.Region)
case "compartmentname":
out.GroupValue = deref(item.CompartmentName)
default:
out.GroupValue = deref(item.Service)
dims := splitGroupBy(groupBy)
if len(dims) == 0 {
dims = []string{"service"}
}
out.GroupValue = dimValue(item, dims[0])
if len(dims) > 1 {
out.SubValue = dimValue(item, dims[1])
}
return out
}
+157
View File
@@ -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()
}
+143
View File
@@ -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")
}
}
}
+104 -16
View File
@@ -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 层负责把缺省字段填成这些默认值)。
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
return idps, nil
}
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT
// 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 {
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
return toIdpInfo(resp.IdentityProvider), err
}
info := toIdpInfo(resp.IdentityProvider)
if !in.JitEnabled {
return info, nil
}
return toIdpInfo(resp.IdentityProvider), nil
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
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}
}
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 接口)。
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
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{
@@ -180,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})
@@ -302,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)
@@ -316,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
}
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
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
}
@@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
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
}
@@ -376,24 +435,53 @@ 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, domainID string) error {
dc, err := c.domainsClient(ctx, cred, region, domainID)
+196
View File
@@ -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)
}
}
+6 -4
View File
@@ -149,15 +149,17 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
return out
}
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(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) {
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
"max_output_tokens": 1, "store": false})
"max_output_tokens": 16, "store": false})
if err != nil {
return 0, err
}
if _, err = c.GenAiCompatResponses(ctx, cred, region, body); 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 {
+94 -31
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
)
@@ -13,18 +14,19 @@ import (
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
const compatResponsesLimit = int64(8 << 20)
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
// 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
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
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)
@@ -32,22 +34,15 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
return nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
if err != nil {
return nil, fmt.Errorf("read compat responses body: %w", err)
}
return payload, nil
return request, nil
}
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
// 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
@@ -55,19 +50,87 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, wait)
request, err := newCompatResponsesRequest(ctx, cred, body)
if err != nil {
return nil, fmt.Errorf("build compat responses stream request: %w", err)
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
return nil, err
}
defer response.Body.Close()
payload, err := 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 response.Body, nil
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
}
+170
View File
@@ -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)
}
})
}
}
+2 -3
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/oracle/oci-go-sdk/v65/common"
@@ -36,9 +35,9 @@ func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, re
return nil, "", err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech")
if err != nil {
return nil, "", fmt.Errorf("read compat speech body: %w", err)
return nil, "", err
}
return payload, response.Header.Get("Content-Type"), nil
}
+27 -7
View File
@@ -49,6 +49,7 @@ type CreateInstanceInput struct {
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
SubnetID string // 为空时自动创建 VCN 与子网
AssignPublicIP bool
ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP
AssignIpv6 bool
SSHPublicKey string // 与 RootPassword 互斥
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
@@ -189,6 +190,10 @@ func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in Cr
}
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
// 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := core.LaunchInstanceDetails{
CompartmentId: &compartmentID,
AvailabilityDomain: &in.AvailabilityDomain,
@@ -397,9 +402,10 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
return
}
var (
wg sync.WaitGroup
mu sync.Mutex
sem = make(chan struct{}, 8)
wg sync.WaitGroup
mu sync.Mutex
sem = make(chan struct{}, 8)
primarySeen = make(map[*Instance]bool)
)
for _, att := range attResp.Items {
inst, ok := active[deref(att.InstanceId)]
@@ -416,16 +422,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
return
}
mu.Lock()
inst.SubnetID = deref(vnicResp.SubnetId)
inst.PrivateIP = deref(vnicResp.PrivateIp)
inst.PublicIP = deref(vnicResp.PublicIp)
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
mu.Unlock()
}(att.VnicId, inst)
}
wg.Wait()
}
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
isPrimary := v.IsPrimary != nil && *v.IsPrimary
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
return
}
inst.SubnetID = deref(v.SubnetId)
inst.PrivateIP = deref(v.PrivateIp)
inst.PublicIP = deref(v.PublicIp)
inst.Ipv6Addresses = v.Ipv6Addresses
if isPrimary {
primarySeen[inst] = true
}
}
func toInstance(inst core.Instance) Instance {
out := Instance{
ID: deref(inst.Id),
+42
View File
@@ -5,9 +5,51 @@ import (
"strings"
"testing"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
func TestApplyVnicAddrs(t *testing.T) {
primary := core.Vnic{
IsPrimary: common.Bool(true),
SubnetId: common.String("sub-a"),
PrivateIp: common.String("10.0.0.2"),
PublicIp: common.String("1.1.1.1"),
}
secondary := core.Vnic{
IsPrimary: common.Bool(false),
SubnetId: common.String("sub-b"),
PrivateIp: common.String("10.0.0.9"),
}
secondary2 := core.Vnic{
SubnetId: common.String("sub-c"),
PrivateIp: common.String("10.0.0.7"),
}
tests := []struct {
name string
order []core.Vnic
wantPrivate string
wantSubnet string
}{
{name: "主卡先到不被次卡覆盖", order: []core.Vnic{primary, secondary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
{name: "次卡先兜底主卡后覆盖", order: []core.Vnic{secondary, primary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
{name: "无主卡时先到者保留", order: []core.Vnic{secondary, secondary2}, wantPrivate: "10.0.0.9", wantSubnet: "sub-b"},
{name: "仅次卡也能兜底", order: []core.Vnic{secondary2}, wantPrivate: "10.0.0.7", wantSubnet: "sub-c"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inst := &Instance{}
seen := make(map[*Instance]bool)
for _, v := range tt.order {
applyVnicAddrs(inst, v, seen)
}
if inst.PrivateIP != tt.wantPrivate || inst.SubnetID != tt.wantSubnet {
t.Errorf("got (%q, %q), want (%q, %q)", inst.PrivateIP, inst.SubnetID, tt.wantPrivate, tt.wantSubnet)
}
})
}
}
func TestShellSingleQuote(t *testing.T) {
tests := []struct {
name string
+178 -69
View File
@@ -2,8 +2,6 @@ package oci
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
@@ -14,14 +12,11 @@ import (
"github.com/oracle/oci-go-sdk/v65/sch"
)
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
// 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生;
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
const (
relayTopicPrefix = "ociportal-logs"
relayPolicyName = "ociportal-logs-sch"
relayConnectorName = "ociportal-logs"
relayAuditLogGroup = "_Audit_Include_Subcompartment"
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
)
@@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er
return sc, nil
}
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
// EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀;
// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
cp, err := c.onsControlClient(cred)
if err != nil {
return RelayResource{}, err
}
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
return res, err
names := relayResourceNames(cred.TenancyOCID)
res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix)
if err != nil {
return RelayResource{}, err
}
name, err := relayTopicNewName()
if ok {
refreshRelayTopicDesc(ctx, cp, res.ID)
return res, nil
}
return createRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix)
}
// createRelayTopic 以指定前缀 + 4 字节随机后缀新建 ONS Topic,描述使用中性文案。
func createRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy, prefix string) (RelayResource, error) {
name, err := relayTopicNewName(prefix)
if err != nil {
return RelayResource{}, err
}
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
CreateTopicDetails: ons.CreateTopicDetails{
Name: &name,
CompartmentId: &cred.TenancyOCID,
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
CompartmentId: &tenancy,
Description: common.String(relayTopicDescNew),
},
})
if err != nil {
@@ -109,28 +116,17 @@ func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (Re
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
}
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期
func relayTopicNewName() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("topic name suffix: %w", err)
}
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
}
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
// findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回
// 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) {
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
for page := 0; page < relayTopicPages; page++ {
list, err := cp.ListTopics(ctx, req)
if err != nil {
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
}
for _, t := range list.Items {
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
}
if res, ok := matchRelayTopic(list.Items, prefixes); ok {
return res, true, nil
}
if list.OpcNextPage == nil {
break
@@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient,
return RelayResource{}, false, nil
}
// matchRelayTopic 在一页 Topic 中挑出第一个命名前缀命中且处于 ACTIVE 的资源。
func matchRelayTopic(items []ons.NotificationTopicSummary, prefixes []string) (RelayResource, bool) {
for _, t := range items {
if t.LifecycleState != ons.NotificationTopicSummaryLifecycleStateActive {
continue
}
name := deref(t.Name)
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true
}
}
}
return RelayResource{}, false
}
// refreshRelayTopicDesc 尽力把 Topic 描述改为中性文案;失败不阻塞主流程(权限不足等场景直接忽略)。
func refreshRelayTopicDesc(ctx context.Context, cp ons.NotificationControlPlaneClient, topicID string) {
if topicID == "" {
return
}
_, _ = cp.UpdateTopic(ctx, ons.UpdateTopicRequest{
TopicId: &topicID,
TopicAttributesDetails: ons.TopicAttributesDetails{
Description: common.String(relayTopicDescNew),
},
})
}
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
@@ -196,28 +221,50 @@ func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials,
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
}
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
// EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名;
// 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return RelayResource{}, err
}
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
})
names := relayResourceNames(cred.TenancyOCID)
res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName)
if err != nil {
return RelayResource{}, fmt.Errorf("list policies: %w", err)
return RelayResource{}, err
}
if len(list.Items) > 0 {
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
if ok {
refreshRelayPolicyDesc(ctx, ic, res.ID)
return res, nil
}
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID)
return createRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName)
}
// findRelayPolicy 依次按传入的名称列表在租户范围内查找 IAM Policy;第一个命中即返回。
func findRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy string, names ...string) (RelayResource, bool, error) {
for _, name := range names {
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &tenancy, Name: common.String(name),
})
if err != nil {
return RelayResource{}, false, fmt.Errorf("list policies: %w", err)
}
if len(list.Items) > 0 {
it := list.Items[0]
return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, nil
}
}
return RelayResource{}, false, nil
}
// createRelayPolicy 以中性描述与派生名称新建 Policy,statement 允许 Service Connector 发布消息到租户内 Topic。
func createRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy, name string) (RelayResource, error) {
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", tenancy)
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
CreatePolicyDetails: identity.CreatePolicyDetails{
CompartmentId: &cred.TenancyOCID,
Name: common.String(relayPolicyName),
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
CompartmentId: &tenancy,
Name: common.String(name),
Description: common.String(relayPolicyDescNew),
Statements: []string{stmt},
},
})
@@ -227,21 +274,45 @@ func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, ho
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
}
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
// refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。
func refreshRelayPolicyDesc(ctx context.Context, ic identity.IdentityClient, id string) {
if id == "" {
return
}
_, _ = ic.UpdatePolicy(ctx, identity.UpdatePolicyRequest{
PolicyId: &id,
UpdatePolicyDetails: identity.UpdatePolicyDetails{
Description: common.String(relayPolicyDescNew),
},
})
}
// EnsureRelayConnector 实现 Client:按新命名返回既有 Connector,fallback 到 legacy DisplayName;
// 命中时对齐 Log Filter 条件,未命中则以新命名新建并轮询至 ACTIVE。
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
sc, err := c.schClient(cred)
if err != nil {
return RelayResource{}, err
}
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
return res, err
names := relayResourceNames(cred.TenancyOCID)
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName)
if err != nil {
return RelayResource{}, err
}
if ok {
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
}
return createRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, topicID, condition)
}
// createRelayConnector 以派生名称新建 Service Connector(_Audit 含子区间 → Topic,按 condition 过滤),
// 新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。Connector 不设 Description,避免恒定文案指纹。
func createRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name, topicID, condition string) (RelayResource, error) {
details := sch.CreateServiceConnectorDetails{
DisplayName: common.String(relayConnectorName),
CompartmentId: &cred.TenancyOCID,
DisplayName: common.String(name),
CompartmentId: &tenancy,
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
CompartmentId: &cred.TenancyOCID,
CompartmentId: &tenancy,
LogGroupId: common.String(relayAuditLogGroup),
}}},
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
@@ -254,27 +325,69 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
}); err != nil {
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
}
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
return waitRelayConnector(ctx, sc, tenancy, name)
}
// findRelayConnector 按名查找存活 Connector。
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
// (如去除 LaunchInstance)后,已建链路经「一键创建」幂等调用原地更新,无需拆除重建。
func reconcileRelayCondition(ctx context.Context, sc sch.ServiceConnectorClient, id, condition string) error {
got, err := sc.GetServiceConnector(ctx, sch.GetServiceConnectorRequest{ServiceConnectorId: &id})
if err != nil {
return fmt.Errorf("get service connector: %w", err)
}
if !relayConditionDiffers(got.Tasks, condition) {
return nil
}
details := sch.UpdateServiceConnectorDetails{Tasks: []sch.TaskDetails{}}
if condition != "" {
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
}
_, err = sc.UpdateServiceConnector(ctx, sch.UpdateServiceConnectorRequest{
ServiceConnectorId: &id, UpdateServiceConnectorDetails: details,
})
if err != nil {
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
return fmt.Errorf("update service connector condition: %w", err)
}
for _, item := range list.Items {
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
return nil
}
// relayConditionDiffers 判断现有任务集与期望条件是否不一致:
// 期望形态是单条 LogRule 条件;任务数、类型或条件文本不同均视为漂移。
func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
if want == "" {
return len(tasks) > 0
}
if len(tasks) != 1 {
return true
}
rule, ok := tasks[0].(sch.LogRuleTaskDetailsResponse)
if !ok || rule.Condition == nil {
return true
}
return *rule.Condition != want
}
// findRelayConnector 依次按传入的 DisplayName 列表在租户范围内查找存活 Connector;第一个命中即返回。
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string, names ...string) (RelayResource, bool, error) {
for _, name := range names {
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
CompartmentId: &tenancy, DisplayName: common.String(name),
})
if err != nil {
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
}
for _, item := range list.Items {
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
}
}
}
return RelayResource{}, false, nil
}
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) {
// 只按新命名查找(新建资源用的就是新命名)。
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name string) (RelayResource, error) {
last := RelayResource{Created: true}
for i := 0; i < relayConnectorPollLimit; i++ {
select {
@@ -282,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
return last, ctx.Err()
case <-time.After(relayConnectorPollTick):
}
res, ok, err := findRelayConnector(ctx, sc, tenancy)
res, ok, err := findRelayConnector(ctx, sc, tenancy, name)
if err != nil {
return last, err
}
@@ -303,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
if err != nil {
return st, err
}
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil {
names := relayResourceNames(cred.TenancyOCID)
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix); err != nil {
return st, err
}
if st.Topic.ID != "" {
@@ -318,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
return c.relayControlState(ctx, cred, st)
}
// relayControlState 补齐 Connector 与 Policy 两项状态。
// relayControlState 补齐 Connector 与 Policy 两项状态,双路径兼容 legacy 命名
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
sc, err := c.schClient(cred)
if err != nil {
return st, err
}
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil {
names := relayResourceNames(cred.TenancyOCID)
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName); err != nil {
return st, err
}
ic, err := c.identityClientAt(cred, "")
if err != nil {
return st, err
}
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
})
if err != nil {
return st, fmt.Errorf("list policies: %w", err)
}
if len(list.Items) > 0 {
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil {
return st, err
}
return st, nil
}
+53
View File
@@ -0,0 +1,53 @@
package oci
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
)
// 日志回传链路资源命名与描述文案的集中定义。
// 目标:让每租户派生互异的中性资源名,避免跨租户恒定字面量成为「共用同一套 oci-portal」的指纹。
// relayNames 是按 tenancy 派生的一组资源名。
type relayNames struct {
TopicPrefix string // 例: "c3f2a1b0-audit",Topic 最终名再加短随机后缀
PolicyName string // 例: "c3f2a1b0-audit-p"
ConnectorName string // 例: "c3f2a1b0-audit"
}
// relayResourceNames 由 tenancyOCID 派生一组稳定的中性资源名。
// SHA-256 前 4 字节做前缀,同 tenancy 每次调用结果一致(幂等查找依赖此性质)。
func relayResourceNames(tenancyOCID string) relayNames {
sum := sha256.Sum256([]byte(tenancyOCID))
prefix := hex.EncodeToString(sum[:4])
return relayNames{
TopicPrefix: prefix + "-audit",
PolicyName: prefix + "-audit-p",
ConnectorName: prefix + "-audit",
}
}
// relayTopicNewName 按给定前缀 + 4 字节随机后缀生成 Topic 名,规避 ONS 删除保留期。
func relayTopicNewName(prefix string) (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("topic name suffix: %w", err)
}
return prefix + "-" + hex.EncodeToString(buf), nil
}
// 描述文案:中性英文,不含品牌明文;集中于此便于后续再调整。
const (
relayTopicDescNew = "Audit event relay endpoint"
relayPolicyDescNew = "Allow Service Connector to publish audit events to notification topic"
)
// legacyRelay* 仅用于识别旧版本(硬编码 ociportal-logs*)创建的存量资源,不用于新建。
// 新租户完全走 relayResourceNames 派生;存量租户 fallback 命中后描述会一次性刷新为中性文案。
const (
legacyRelayTopicPrefix = "ociportal-logs"
legacyRelayPolicyName = "ociportal-logs-sch"
legacyRelayConnectorName = "ociportal-logs"
)
+115
View File
@@ -0,0 +1,115 @@
package oci
import (
"regexp"
"strings"
"testing"
)
func TestRelayResourceNames_Deterministic(t *testing.T) {
tenancy := "ocid1.tenancy.oc1..aaaaexampletenant"
a := relayResourceNames(tenancy)
b := relayResourceNames(tenancy)
if a != b {
t.Fatalf("同 tenancy 派生不一致: %+v vs %+v", a, b)
}
}
func TestRelayResourceNames_DistinctTenancies(t *testing.T) {
cases := []string{
"ocid1.tenancy.oc1..aaaaaaaaaa",
"ocid1.tenancy.oc1..bbbbbbbbbb",
"ocid1.tenancy.oc1..cccccccccc",
}
seen := map[string]string{}
for _, tenancy := range cases {
n := relayResourceNames(tenancy)
if got, ok := seen[n.TopicPrefix]; ok {
t.Fatalf("前缀冲突: %s 与 %s 同为 %s", tenancy, got, n.TopicPrefix)
}
seen[n.TopicPrefix] = tenancy
assertNoBrandLeak(t, tenancy, n)
}
}
// assertNoBrandLeak 断言派生结果不含品牌明文,以及格式符合预期。
func assertNoBrandLeak(t *testing.T, tenancy string, n relayNames) {
t.Helper()
banned := []string{"oci-portal", "ociportal", "logs", "portal"}
fields := []struct {
name string
value string
}{
{"TopicPrefix", n.TopicPrefix},
{"PolicyName", n.PolicyName},
{"ConnectorName", n.ConnectorName},
}
for _, f := range fields {
lower := strings.ToLower(f.value)
for _, b := range banned {
if strings.Contains(lower, b) {
t.Errorf("tenancy %s: %s=%q 含品牌明文 %q", tenancy, f.name, f.value, b)
}
}
if !regexp.MustCompile(`^[0-9a-f]{8}-audit(-p)?$`).MatchString(f.value) {
t.Errorf("tenancy %s: %s=%q 不符合 <8hex>-audit(-p)? 格式", tenancy, f.name, f.value)
}
}
}
func TestRelayTopicNewName_Suffix(t *testing.T) {
tests := []struct {
name string
prefix string
}{
{name: "标准前缀", prefix: "c3f2a1b0-audit"},
{name: "legacy 前缀兼容", prefix: legacyRelayTopicPrefix},
}
pat := regexp.MustCompile(`^[0-9a-f]{8}$`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := relayTopicNewName(tt.prefix)
if err != nil {
t.Fatalf("生成失败: %v", err)
}
if !strings.HasPrefix(got, tt.prefix+"-") {
t.Errorf("前缀不符: got=%q, prefix=%q", got, tt.prefix)
}
suffix := strings.TrimPrefix(got, tt.prefix+"-")
if !pat.MatchString(suffix) {
t.Errorf("后缀非 8 hex: got=%q, suffix=%q", got, suffix)
}
})
}
}
func TestLegacyPrefixes_Unchanged(t *testing.T) {
cases := []struct {
name string
got string
want string
}{
{name: "Topic legacy", got: legacyRelayTopicPrefix, want: "ociportal-logs"},
{name: "Policy legacy", got: legacyRelayPolicyName, want: "ociportal-logs-sch"},
{name: "Connector legacy", got: legacyRelayConnectorName, want: "ociportal-logs"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("legacy 常量被误改: got=%q, want=%q", tt.got, tt.want)
}
})
}
}
func TestRelayDescriptions_Neutral(t *testing.T) {
banned := []string{"oci-portal", "ociportal", "日志回传", "面板"}
for _, desc := range []string{relayTopicDescNew, relayPolicyDescNew} {
lower := strings.ToLower(desc)
for _, b := range banned {
if strings.Contains(lower, strings.ToLower(b)) {
t.Errorf("描述含品牌明文 %q: %q", b, desc)
}
}
}
}
+30
View File
@@ -3,6 +3,8 @@ package oci
import (
"strings"
"testing"
"github.com/oracle/oci-go-sdk/v65/sch"
)
func TestRelayEventCondition(t *testing.T) {
@@ -35,3 +37,31 @@ func TestRelayEventConditionQuoting(t *testing.T) {
t.Errorf("单引号数 = %d, want 2 (%s)", strings.Count(cond, "'"), cond)
}
}
func TestRelayConditionDiffers(t *testing.T) {
cond := "data.eventName='TerminateInstance'"
rule := func(c string) sch.TaskDetailsResponse {
return sch.LogRuleTaskDetailsResponse{Condition: &c}
}
tests := []struct {
name string
tasks []sch.TaskDetailsResponse
want string
diff bool
}{
{name: "条件一致不漂移", tasks: []sch.TaskDetailsResponse{rule(cond)}, want: cond, diff: false},
{name: "条件文本不同漂移", tasks: []sch.TaskDetailsResponse{rule("data.eventName='LaunchInstance'")}, want: cond, diff: true},
{name: "无任务但期望条件漂移", tasks: nil, want: cond, diff: true},
{name: "多任务漂移", tasks: []sch.TaskDetailsResponse{rule(cond), rule(cond)}, want: cond, diff: true},
{name: "期望空且无任务不漂移", tasks: nil, want: "", diff: false},
{name: "期望空但有任务漂移", tasks: []sch.TaskDetailsResponse{rule(cond)}, want: "", diff: true},
{name: "条件缺失漂移", tasks: []sch.TaskDetailsResponse{sch.LogRuleTaskDetailsResponse{}}, want: cond, diff: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := relayConditionDiffers(tt.tasks, tt.want); got != tt.diff {
t.Errorf("differs = %v, want %v", got, tt.diff)
}
})
}
}
+2 -2
View File
@@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc
return toVCN(resp.Vcn), nil
}
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 /
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
// DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 /
// 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
+755
View File
@@ -0,0 +1,755 @@
// 对象存储:namespace / 桶 / 对象 / 预签名请求(PAR)。
// 上传下载数据面走 PAR 直连 OCI,面板只做控制面;
// 例外:预览/编辑的小文件经面板中转(GetObject/PutObject),不签发 PAR。
package oci
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
)
// ErrObjectTooLarge 表示对象超出面板中转大小上限。
var ErrObjectTooLarge = errors.New("object too large for inline transfer")
// ObjectContent 是对象内容与写入所需元数据(面板中转小文件用)。
type ObjectContent struct {
Data []byte
ContentType string
Etag string
}
// Bucket 是存储桶摘要(含用量估算,来自 GetBucket 的 approximate 字段)。
type Bucket struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Visibility string `json:"visibility"` // NoPublicAccess / ObjectRead
StorageTier string `json:"storageTier"`
VersioningOn bool `json:"versioningOn"`
ApproximateCount int64 `json:"approximateCount"`
ApproximateSize int64 `json:"approximateSize"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ObjectSummary 是对象列表行;文件夹以 prefixes 单独返回。
type ObjectSummary struct {
Name string `json:"name"`
Size int64 `json:"size"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
}
// ListObjectsResult 是分页对象列表:delimiter="/" 模式,子层级归入 Prefixes。
type ListObjectsResult struct {
Objects []ObjectSummary `json:"objects"`
Prefixes []string `json:"prefixes"`
NextStartWith string `json:"nextStartWith"`
}
// ObjectDetail 是对象元数据(HEAD)。
type ObjectDetail struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
Etag string `json:"etag"`
ContentMd5 string `json:"contentMd5"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
Metadata map[string]string `json:"metadata"`
}
// PAR 是预签名请求摘要;FullURL 仅创建响应携带(OCI 事后不可再取回)。
type PAR struct {
ID string `json:"id"`
Name string `json:"name"`
ObjectName string `json:"objectName"`
AccessType string `json:"accessType"`
FullURL string `json:"fullUrl,omitempty"`
TimeExpires *time.Time `json:"timeExpires"`
TimeCreated *time.Time `json:"timeCreated"`
}
// CreateBucketInput 创建桶参数;CompartmentID 空为生效 compartment。
type CreateBucketInput struct {
Name string
CompartmentID string
PublicRead bool
StorageTier string // Standard / Archive
VersioningOn bool
}
// UpdateBucketInput 更新桶参数;nil 字段不改。
type UpdateBucketInput struct {
PublicRead *bool
VersioningOn *bool
}
// CreatePARInput 签发预签名请求参数。
type CreatePARInput struct {
Name string
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectRead / AnyObjectWrite / AnyObjectReadWrite
ExpiresHours int
}
// bulkRetryPolicy 批量删除(清空桶)的限流退避:16 并发下 OCI 可能回 429。
// 不用 DefaultRetryPolicy:其 8 次尝试、退避上限 30s,且带最终一致性模式
// (9 次、上限 45s)——大批量删除里 worker 一旦触发就长睡,吞吐塌方;
// 这里收紧为 5 次、上限 3s、关掉最终一致性,单条最坏 ~13s 后放行不拖全场。
var bulkRetryPolicy = common.NewRetryPolicyWithOptions(
common.ReplaceWithValuesFromRetryPolicy(common.DefaultRetryPolicyWithoutEventualConsistency()),
common.WithMaximumNumberAttempts(5),
common.WithExponentialBackoff(3*time.Second, 2.0),
)
func (c *RealClient) osClient(cred Credentials, region string) (objectstorage.ObjectStorageClient, error) {
oc, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider(cred))
if err != nil {
return oc, fmt.Errorf("new object storage client: %w", err)
}
applyProxy(&oc.BaseClient, cred)
if region != "" {
oc.SetRegion(normalizeRegion(region))
}
return oc, nil
}
// GetObjectStorageNamespace 实现 Client:租户 namespace(全租户唯一,常量语义),
// 首次回源后按 tenancy 进程内缓存;此前每个对象存储操作都远程取一次,
// 经代理链路时白付一整次冷连接往返。并发首次可能重复回源,结果相同无害。
func (c *RealClient) GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) {
if v, ok := c.namespaces.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
resp, err := oc.GetNamespace(ctx, objectstorage.GetNamespaceRequest{})
if err != nil {
return "", fmt.Errorf("get namespace: %w", err)
}
if cred.TenancyOCID != "" {
c.namespaces.Store(cred.TenancyOCID, deref(resp.Value))
}
return deref(resp.Value), nil
}
// ListBuckets 实现 Client:列出指定 compartment 下的桶并逐个补全用量(空为生效 compartment)。
func (c *RealClient) ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, err
}
resp, err := oc.ListBuckets(ctx, objectstorage.ListBucketsRequest{
NamespaceName: &ns,
CompartmentId: hostCompartment(cred, compartmentID),
})
if err != nil {
return nil, fmt.Errorf("list buckets: %w", err)
}
out := make([]Bucket, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, c.bucketDetail(ctx, oc, ns, deref(it.Name)))
}
return out, nil
}
// bucketDetail 取单桶详情;失败时退化为只有名字的摘要,不拖垮列表。
func (c *RealClient) bucketDetail(ctx context.Context, oc objectstorage.ObjectStorageClient, ns, name string) Bucket {
resp, err := oc.GetBucket(ctx, objectstorage.GetBucketRequest{
NamespaceName: &ns,
BucketName: &name,
Fields: []objectstorage.GetBucketFieldsEnum{"approximateCount", "approximateSize"},
})
if err != nil {
return Bucket{Name: name, Namespace: ns}
}
b := Bucket{
Name: name,
Namespace: ns,
Visibility: string(resp.PublicAccessType),
StorageTier: string(resp.StorageTier),
VersioningOn: resp.Versioning == objectstorage.BucketVersioningEnabled,
ApproximateCount: derefI64(resp.ApproximateCount),
ApproximateSize: derefI64(resp.ApproximateSize),
}
if resp.TimeCreated != nil {
b.TimeCreated = &resp.TimeCreated.Time
}
return b
}
func derefI64(p *int64) int64 {
if p != nil {
return *p
}
return 0
}
// CreateBucket 实现 Client。
func (c *RealClient) CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.CreateBucketDetails{
Name: &in.Name,
CompartmentId: hostCompartment(cred, in.CompartmentID),
PublicAccessType: objectstorage.CreateBucketDetailsPublicAccessTypeNopublicaccess,
StorageTier: objectstorage.CreateBucketDetailsStorageTierStandard,
}
if in.PublicRead {
details.PublicAccessType = objectstorage.CreateBucketDetailsPublicAccessTypeObjectread
}
if strings.EqualFold(in.StorageTier, "Archive") {
details.StorageTier = objectstorage.CreateBucketDetailsStorageTierArchive
}
if in.VersioningOn {
details.Versioning = objectstorage.CreateBucketDetailsVersioningEnabled
}
if _, err := oc.CreateBucket(ctx, objectstorage.CreateBucketRequest{
NamespaceName: &ns, CreateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("create bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, in.Name), nil
}
// UpdateBucket 实现 Client:改可见性 / 版本控制。
func (c *RealClient) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.UpdateBucketDetails{}
if in.PublicRead != nil {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeNopublicaccess
if *in.PublicRead {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeObjectread
}
}
if in.VersioningOn != nil {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningSuspended
if *in.VersioningOn {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningEnabled
}
}
if _, err := oc.UpdateBucket(ctx, objectstorage.UpdateBucketRequest{
NamespaceName: &ns, BucketName: &name, UpdateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("update bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, name), nil
}
// DeleteBucket 实现 Client:仅空桶可删,OCI 拒绝非空桶。
// ErrBucketNotEmpty 表示桶内仍有对象 / 历史版本 / 未完成分片上传,OCI 拒绝删除。
var ErrBucketNotEmpty = errors.New("bucket not empty")
func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteBucket(ctx, objectstorage.DeleteBucketRequest{
NamespaceName: &ns, BucketName: &name,
}); err != nil {
if isBucketNotEmptyErr(err) {
return fmt.Errorf("delete bucket %s: %w", name, ErrBucketNotEmpty)
}
return fmt.Errorf("delete bucket: %w", err)
}
return nil
}
// AbortAllMultipartUploads 实现 Client:中止桶内全部未完成分片上传并删除已传分片。
// 未完成分片会让「已清空」的桶仍以非空拒绝删除;404 视为桶已不存在,幂等成功。
func (c *RealClient) AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
for page := (*string)(nil); ; {
resp, err := oc.ListMultipartUploads(ctx, objectstorage.ListMultipartUploadsRequest{
NamespaceName: &ns, BucketName: &bucket, Page: page,
})
if err != nil {
return ignoreNotFound(fmt.Errorf("list multipart uploads: %w", err))
}
for _, up := range resp.Items {
if _, err := oc.AbortMultipartUpload(ctx, objectstorage.AbortMultipartUploadRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: up.Object, UploadId: up.UploadId,
}); err != nil {
if err := ignoreNotFound(fmt.Errorf("abort multipart upload: %w", err)); err != nil {
return err
}
}
}
if page = resp.OpcNextPage; page == nil {
return nil
}
}
}
// ignoreNotFound 把上游 404 归一为 nil(资源已不存在,操作目的已达成)。
func ignoreNotFound(err error) error {
var se common.ServiceError
if errors.As(err, &se) && se.GetHTTPStatusCode() == 404 {
return nil
}
return err
}
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
// 两者都可经后台清空(对象版本+PAR)后重删。
func isBucketNotEmptyErr(err error) bool {
var se common.ServiceError
if !errors.As(err, &se) {
return false
}
if se.GetCode() == "BucketNotEmpty" {
return true
}
return se.GetHTTPStatusCode() == 409 &&
strings.Contains(se.GetMessage(), "Preauthenticated Request")
}
// ObjectVersion 是对象版本摘要,清空桶时逐版本删除用。
type ObjectVersion struct {
Name string `json:"name"`
VersionID string `json:"versionId"`
}
// ListObjectVersions 实现 Client:分页列出全部对象版本(未开版本控制的桶返回当前版本)。
func (c *RealClient) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListObjectVersionsRequest{
NamespaceName: &ns, BucketName: &bucket, Limit: common.Int(1000),
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListObjectVersions(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list object versions: %w", err)
}
out := make([]ObjectVersion, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, ObjectVersion{Name: deref(it.Name), VersionID: deref(it.VersionId)})
}
return out, deref(resp.OpcNextPage), nil
}
// DeleteObjectVersion 实现 Client:删除指定版本(versionID 空为当前版本)。
func (c *RealClient) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}
if versionID != "" {
req.VersionId = &versionID
}
if _, err := oc.DeleteObject(ctx, req); err != nil {
return fmt.Errorf("delete object version: %w", err)
}
return nil
}
// ListObjects 实现 Client:delimiter="/" 前缀模式;startWith 为上页游标。
func (c *RealClient) ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ListObjectsResult{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ListObjectsResult{}, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
req := objectstorage.ListObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
Delimiter: common.String("/"),
Limit: &limit,
Fields: common.String("name,size,timeModified,storageTier,archivalState"),
}
if prefix != "" {
req.Prefix = &prefix
}
if startWith != "" {
req.Start = &startWith
}
resp, err := oc.ListObjects(ctx, req)
if err != nil {
return ListObjectsResult{}, fmt.Errorf("list objects: %w", err)
}
return toListObjectsResult(resp), nil
}
func toListObjectsResult(resp objectstorage.ListObjectsResponse) ListObjectsResult {
out := ListObjectsResult{
Objects: make([]ObjectSummary, 0, len(resp.Objects)),
Prefixes: orEmpty(resp.Prefixes),
NextStartWith: deref(resp.NextStartWith),
}
for _, o := range resp.Objects {
item := ObjectSummary{
Name: deref(o.Name),
Size: derefI64(o.Size),
StorageTier: string(o.StorageTier),
ArchivalState: string(o.ArchivalState),
}
if o.TimeModified != nil {
item.TimeModified = &o.TimeModified.Time
}
out.Objects = append(out.Objects, item)
}
return out
}
// DeleteObject 实现 Client。
func (c *RealClient) DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteObject(ctx, objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
}); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
// RenameObject 实现 Client:OCI 原生重命名(同桶内)。
func (c *RealClient) RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.RenameObject(ctx, objectstorage.RenameObjectRequest{
NamespaceName: &ns,
BucketName: &bucket,
RenameObjectDetails: objectstorage.RenameObjectDetails{
SourceName: &src,
NewName: &dst,
},
}); err != nil {
return fmt.Errorf("rename object: %w", err)
}
return nil
}
// RestoreObject 实现 Client:Archive 对象取回,hours 为可下载时长(默认 24)。
func (c *RealClient) RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.RestoreObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
RestoreObjectsDetails: objectstorage.RestoreObjectsDetails{
ObjectName: &object,
},
}
if hours > 0 {
req.RestoreObjectsDetails.Hours = &hours
}
if _, err := oc.RestoreObjects(ctx, req); err != nil {
return fmt.Errorf("restore object: %w", err)
}
return nil
}
// HeadObject 实现 Client:对象元数据。
func (c *RealClient) HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectDetail{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectDetail{}, err
}
resp, err := oc.HeadObject(ctx, objectstorage.HeadObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectDetail{}, fmt.Errorf("head object: %w", err)
}
d := ObjectDetail{
Name: object,
Size: derefI64(resp.ContentLength),
ContentType: deref(resp.ContentType),
Etag: deref(resp.ETag),
ContentMd5: deref(resp.ContentMd5),
StorageTier: string(resp.StorageTier),
ArchivalState: string(resp.ArchivalState),
Metadata: resp.OpcMeta,
}
if resp.LastModified != nil {
d.TimeModified = &resp.LastModified.Time
}
return d, nil
}
// GetObject 实现 Client:整体读入对象内容;超过 maxBytes 返回 ErrObjectTooLarge。
func (c *RealClient) GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectContent{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectContent{}, err
}
resp, err := oc.GetObject(ctx, objectstorage.GetObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectContent{}, fmt.Errorf("get object: %w", err)
}
defer resp.Content.Close()
if derefI64(resp.ContentLength) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s (%d bytes): %w", object, derefI64(resp.ContentLength), ErrObjectTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return ObjectContent{}, fmt.Errorf("read object body: %w", err)
}
if int64(len(data)) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s: %w", object, ErrObjectTooLarge)
}
return ObjectContent{Data: data, ContentType: deref(resp.ContentType), Etag: deref(resp.ETag)}, nil
}
// PutObject 实现 Client:整体写入对象;ifMatch 非空时作为 If-Match 条件,
// 不匹配由 OCI 返回 412(经 ServiceError 透出)。返回新 ETag。
func (c *RealClient) PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return "", err
}
length := int64(len(data))
req := objectstorage.PutObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
ContentLength: &length,
PutObjectBody: io.NopCloser(bytes.NewReader(data)),
}
if contentType != "" {
req.ContentType = &contentType
}
if ifMatch != "" {
req.IfMatch = &ifMatch
}
resp, err := oc.PutObject(ctx, req)
if err != nil {
return "", fmt.Errorf("put object: %w", err)
}
return deref(resp.ETag), nil
}
// CreatePAR 实现 Client:签发预签名请求并拼出完整 URL(仅此时可得)。
func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return PAR{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return PAR{}, err
}
details := createPARDetails(in, time.Now())
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
})
if err != nil {
return PAR{}, fmt.Errorf("create par: %w", err)
}
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
}
// createPARDetails 集中组装 SDK 请求,避免 accessType 与桶列举权限组合回归。
func createPARDetails(in CreatePARInput, now time.Time) objectstorage.CreatePreauthenticatedRequestDetails {
hours := in.ExpiresHours
if hours <= 0 {
hours = 24
}
details := objectstorage.CreatePreauthenticatedRequestDetails{
Name: &in.Name,
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
TimeExpires: &common.SDKTime{Time: now.Add(time.Duration(hours) * time.Hour)},
}
if in.ObjectName != "" {
details.ObjectName = &in.ObjectName
}
if in.AccessType == "AnyObjectRead" || in.AccessType == "AnyObjectReadWrite" {
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
}
return details
}
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.AccessUri != nil {
out.FullURL = strings.TrimSuffix(endpoint, "/") + *p.AccessUri
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// ListPARsPage 实现 Client:单页列出桶内预签名请求(摘要不含 URL),
// 返回下一页游标,空串表示已到末页;limit ≤0 时用 OCI 默认页大小。
func (c *RealClient) ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListPreauthenticatedRequestsRequest{NamespaceName: &ns, BucketName: &bucket}
if limit > 0 {
req.Limit = common.Int(limit)
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListPreauthenticatedRequests(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list pars: %w", err)
}
out := make([]PAR, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, summaryPAR(it))
}
return out, deref(resp.OpcNextPage), nil
}
// ListPARs 实现 Client:翻页列出桶内全部有效预签名请求(清空/全部删除用)。
func (c *RealClient) ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) {
out := make([]PAR, 0, 16)
page := ""
for {
items, next, err := c.ListPARsPage(ctx, cred, region, bucket, page, 1000)
if err != nil {
return nil, err
}
out = append(out, items...)
if next == "" {
return out, nil
}
page = next
}
}
func summaryPAR(p objectstorage.PreauthenticatedRequestSummary) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// DeletePAR 实现 Client:撤销预签名请求,链接立即失效。
func (c *RealClient) DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeletePreauthenticatedRequest(ctx, objectstorage.DeletePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, ParId: &parID,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}); err != nil {
return fmt.Errorf("delete par: %w", err)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package oci
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
// (凭据为空也能取到,证明未走回源路径)。
func TestGetObjectStorageNamespaceCached(t *testing.T) {
c := NewClient()
tests := []struct {
name string
tenancy string
seed string
}{
{name: "缓存命中直接返回", tenancy: "ocid1.tenancy.oc1..aaaa", seed: "ns-a"},
{name: "不同租户各取各的", tenancy: "ocid1.tenancy.oc1..bbbb", seed: "ns-b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c.namespaces.Store(tt.tenancy, tt.seed)
got, err := c.GetObjectStorageNamespace(context.Background(), Credentials{TenancyOCID: tt.tenancy}, "us-ashburn-1")
if err != nil || got != tt.seed {
t.Fatalf("GetObjectStorageNamespace = %q, %v; want %q", got, err, tt.seed)
}
})
}
}
func TestCreatePARDetailsBucketListingAction(t *testing.T) {
cases := []struct {
accessType string
wantList bool
}{
{"AnyObjectRead", true},
{"AnyObjectReadWrite", true},
{"AnyObjectWrite", false},
{"ObjectRead", false},
}
for _, tc := range cases {
details := createPARDetails(CreatePARInput{AccessType: tc.accessType, ExpiresHours: 1}, time.Unix(100, 0))
gotList := string(details.BucketListingAction) == "ListObjects"
if gotList != tc.wantList {
t.Errorf("%s listing = %q, wantList %v", tc.accessType, details.BucketListingAction, tc.wantList)
}
wire, err := json.Marshal(details)
if err != nil {
t.Fatalf("marshal %s details: %v", tc.accessType, err)
}
gotWireList := strings.Contains(string(wire), `"bucketListingAction":"ListObjects"`)
if gotWireList != tc.wantList {
t.Errorf("%s wire = %s, wantList %v", tc.accessType, wire, tc.wantList)
}
}
}
func TestCreatePARDetailsExpirationAndObject(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
details := createPARDetails(CreatePARInput{
Name: "share", ObjectName: "folder/a.txt", AccessType: "ObjectRead", ExpiresHours: 48,
}, now)
if got, want := details.TimeExpires.Time, now.Add(48*time.Hour); !got.Equal(want) {
t.Errorf("TimeExpires = %v, want %v", got, want)
}
if details.ObjectName == nil || *details.ObjectName != "folder/a.txt" {
t.Errorf("ObjectName = %v, want folder/a.txt", details.ObjectName)
}
defaults := createPARDetails(CreatePARInput{AccessType: "ObjectRead"}, now)
if got, want := defaults.TimeExpires.Time, now.Add(24*time.Hour); !got.Equal(want) {
t.Errorf("default TimeExpires = %v, want %v", got, want)
}
}
+49 -5
View File
@@ -2,8 +2,10 @@ package oci
import (
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
"golang.org/x/net/proxy"
@@ -23,6 +25,27 @@ type ProxySpec struct {
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
const proxyClientTimeout = 60 * time.Second
// 阶段超时对齐 SDK 直连 Transport 模板(transport_template_provider):连不上的
// 代理快速失败,而不是拖满总超时;responses 直通去掉总超时后这是建立阶段的兜底之一。
const (
proxyDialTimeout = 30 * time.Second
proxyTLSHandshakeTimeout = 10 * time.Second
)
// 连接池参数:PerHost 须 > 批量删除并发数(service 层 16)——并发拨号竞速时
// Transport 会投机新建连接,池上限过紧会让这些连接用一次即被丢弃(churn);
// IdleConnTimeout 显式关闭空闲连接(零值=永不关,只能等远端 ~65s 断开,连接会堆积)。
const (
proxyMaxIdleConns = 128
proxyMaxIdleConnsPerHost = 32
proxyIdleConnTimeout = 90 * time.Second
)
// proxyClients 按代理配置复用 http.Client。SDK client 每次操作新建,
// 若 Transport 也随之新建则连接零复用:每个请求都要付完整的
// TCP+SOCKS/CONNECT+TLS 握手(经代理 3+ 个 RTT),批量操作被拖到分钟级。
var proxyClients sync.Map // ProxySpec(值) → *http.Client
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
func applyProxy(base *common.BaseClient, cred Credentials) {
@@ -31,16 +54,21 @@ func applyProxy(base *common.BaseClient, cred Credentials) {
}
}
// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。
// proxyHTTPClient 按代理配置取共享 http.Client;nil 或非法配置返回 nil(走直连)。
// 同配置复用同一实例(连接池随之复用);调用方只可包装、不得改写其字段。
func proxyHTTPClient(p *ProxySpec) *http.Client {
if p == nil || p.Host == "" || p.Port <= 0 {
return nil
}
if v, ok := proxyClients.Load(*p); ok {
return v.(*http.Client)
}
tr := transportFor(p)
if tr == nil {
return nil
}
return &http.Client{Transport: tr, Timeout: proxyClientTimeout}
v, _ := proxyClients.LoadOrStore(*p, &http.Client{Transport: tr, Timeout: proxyClientTimeout})
return v.(*http.Client)
}
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
@@ -49,21 +77,35 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
return proxyHTTPClient(p)
}
// pooledTransport 连接池参数统一的 Transport 骨架;阶段超时见常量注释。
func pooledTransport() *http.Transport {
return &http.Transport{
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
MaxIdleConns: proxyMaxIdleConns,
MaxIdleConnsPerHost: proxyMaxIdleConnsPerHost,
IdleConnTimeout: proxyIdleConnTimeout,
}
}
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
func transportFor(p *ProxySpec) *http.Transport {
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
dialer := &net.Dialer{Timeout: proxyDialTimeout}
if p.Type == "http" || p.Type == "https" {
u := &url.URL{Scheme: p.Type, Host: addr}
if p.Username != "" {
u.User = url.UserPassword(p.Username, p.Password)
}
return &http.Transport{Proxy: http.ProxyURL(u)}
tr := pooledTransport()
tr.Proxy = http.ProxyURL(u)
tr.DialContext = dialer.DialContext
return tr
}
var auth *proxy.Auth
if p.Username != "" {
auth = &proxy.Auth{User: p.Username, Password: p.Password}
}
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
d, err := proxy.SOCKS5("tcp", addr, auth, dialer)
if err != nil {
return nil
}
@@ -71,5 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport {
if !ok {
return nil
}
return &http.Transport{DialContext: cd.DialContext}
tr := pooledTransport()
tr.DialContext = cd.DialContext
return tr
}
+91
View File
@@ -0,0 +1,91 @@
package oci
import (
"testing"
)
func TestTransportForStageTimeouts(t *testing.T) {
tests := []struct {
name string
spec *ProxySpec
wantProxy bool // CONNECT 分支应设 Proxy 函数
}{
{name: "http CONNECT 代理", spec: &ProxySpec{Type: "http", Host: "127.0.0.1", Port: 8080}, wantProxy: true},
{name: "https CONNECT 代理", spec: &ProxySpec{Type: "https", Host: "127.0.0.1", Port: 8443}, wantProxy: true},
{name: "socks5 代理", spec: &ProxySpec{Type: "socks5", Host: "127.0.0.1", Port: 1080, Username: "u", Password: "p"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tr := transportFor(tt.spec)
if tr == nil {
t.Fatal("transportFor 返回 nil")
}
if (tr.Proxy != nil) != tt.wantProxy {
t.Fatalf("Proxy 函数存在性 = %v, 期望 %v", tr.Proxy != nil, tt.wantProxy)
}
if tr.DialContext == nil {
t.Fatal("应设置带超时的 DialContext")
}
if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout {
t.Fatalf("TLSHandshakeTimeout = %v, 期望 %v", tr.TLSHandshakeTimeout, proxyTLSHandshakeTimeout)
}
if tr.MaxIdleConnsPerHost != proxyMaxIdleConnsPerHost || tr.IdleConnTimeout != proxyIdleConnTimeout {
t.Fatalf("连接池参数未设置: PerHost=%d IdleTimeout=%v", tr.MaxIdleConnsPerHost, tr.IdleConnTimeout)
}
})
}
}
func TestProxyHTTPClientReuse(t *testing.T) {
tests := []struct {
name string
a, b *ProxySpec
wantSame bool
}{
{
name: "同配置复用同一实例",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
wantSame: true,
},
{
name: "不同配置各自实例",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.2", Port: 1080},
},
{
name: "同地址不同凭据不复用",
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u1", Password: "p1"},
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u2", Password: "p2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ca, cb := proxyHTTPClient(tt.a), proxyHTTPClient(tt.b)
if ca == nil || cb == nil {
t.Fatal("proxyHTTPClient 返回 nil")
}
if (ca == cb) != tt.wantSame {
t.Fatalf("实例复用 = %v, 期望 %v", ca == cb, tt.wantSame)
}
})
}
}
func TestProxyHTTPClientInvalidSpec(t *testing.T) {
tests := []struct {
name string
spec *ProxySpec
}{
{name: "nil 配置", spec: nil},
{name: "缺 host", spec: &ProxySpec{Type: "socks5", Port: 1080}},
{name: "非法端口", spec: &ProxySpec{Type: "socks5", Host: "10.0.0.1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if c := proxyHTTPClient(tt.spec); c != nil {
t.Fatalf("非法配置应返回 nil,得到 %v", c)
}
})
}
}
+62 -8
View File
@@ -3,7 +3,9 @@ package oci
import (
"context"
"fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
@@ -62,7 +64,7 @@ func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIP
}
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作
// 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新的临时 IP
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
@@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
if err != nil {
return "", err
}
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil {
if existing.Lifetime == core.PublicIpLifetimeReserved {
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
}
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return "", fmt.Errorf("delete old public ip: %w", err)
}
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
}
// ChangeVnicPublicIP 实现 Client:为指定 VNIC 的主私有 IP 更换临时公网 IP(次要网卡场景)。
func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return "", err
}
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
if err != nil {
return "", err
}
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
}
// replaceEphemeralPublicIP 释放私有 IP 上现有公网 IP(临时删除、保留解绑)并分配新临时 IP。
func replaceEphemeralPublicIP(ctx context.Context, vn core.VirtualNetworkClient, cred Credentials, priv core.PrivateIp) (string, error) {
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return "", err
}
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
@@ -94,6 +109,45 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
return deref(resp.IpAddress), nil
}
// detachExistingPublicIP 释放私有 IP 上已绑定的公网 IP:
// 临时 IP 直接删除;保留 IP 仅解绑(回到未分配状态,资源不删除)。
func detachExistingPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) error {
existing := lookupPublicIP(ctx, vn, privateIPID)
if existing == nil {
return nil
}
if existing.Lifetime != core.PublicIpLifetimeReserved {
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return fmt.Errorf("delete old public ip: %w", err)
}
return nil
}
_, err := vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: existing.Id,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: common.String("")},
})
if err != nil {
return fmt.Errorf("unassign reserved public ip: %w", err)
}
waitPublicIPDetached(ctx, vn, privateIPID)
return nil
}
// waitPublicIPDetached 短轮询等私有 IP 上的旧绑定释放;保留 IP 解绑为异步操作,
// 立即创建新临时 IP 可能撞上未释放的旧绑定。超时不报错,交由后续创建操作反馈。
func waitPublicIPDetached(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) {
for i := 0; i < 10; i++ {
if lookupPublicIP(ctx, vn, privateIPID) == nil {
return
}
select {
case <-ctx.Done():
return
case <-time.After(500 * time.Millisecond):
}
}
}
// primaryVnic 返回实例的主 VNIC。
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
+197
View File
@@ -0,0 +1,197 @@
package oci
import (
"context"
"fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
// ReservedIP 是保留公网 IP 摘要;绑定目标尽力反查,失败时对应字段为空。
type ReservedIP struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
IPAddress string `json:"ipAddress"`
LifecycleState string `json:"lifecycleState"`
AssignedInstanceID string `json:"assignedInstanceId"`
AssignedInstanceName string `json:"assignedInstanceName"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ListReservedIPs 实现 Client:列出区间内全部保留公网 IP(REGION 作用域);
// compartmentID 为空时用生效 compartment。
func (c *RealClient) ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return nil, err
}
cc, err := c.computeClient(cred, region)
if err != nil {
return nil, err
}
out := make([]ReservedIP, 0, 4)
var page *string
for {
resp, err := vn.ListPublicIps(ctx, core.ListPublicIpsRequest{
Scope: core.ListPublicIpsScopeRegion,
Lifetime: core.ListPublicIpsLifetimeReserved,
CompartmentId: hostCompartment(cred, compartmentID),
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list reserved ips: %w", err)
}
for i := range resp.Items {
out = append(out, c.toReservedIP(ctx, vn, cc, resp.Items[i]))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return out, nil
}
// toReservedIP 转换 SDK 对象并尽力反查绑定的实例。
func (c *RealClient) toReservedIP(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, p core.PublicIp) ReservedIP {
var created *time.Time
if p.TimeCreated != nil {
created = &p.TimeCreated.Time
}
r := ReservedIP{
ID: deref(p.Id),
DisplayName: deref(p.DisplayName),
IPAddress: deref(p.IpAddress),
LifecycleState: string(p.LifecycleState),
TimeCreated: created,
}
if p.AssignedEntityType == core.PublicIpAssignedEntityTypePrivateIp && p.AssignedEntityId != nil {
r.AssignedInstanceID, r.AssignedInstanceName = resolveIPAssignee(ctx, vn, cc, *p.AssignedEntityId)
}
return r
}
// resolveIPAssignee 由私有 IP 反查所属实例;任一步失败即放弃,不影响列表主体。
func resolveIPAssignee(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, privateIPID string) (string, string) {
priv, err := vn.GetPrivateIp(ctx, core.GetPrivateIpRequest{PrivateIpId: &privateIPID})
if err != nil || priv.VnicId == nil {
return "", ""
}
atts, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
CompartmentId: priv.CompartmentId,
VnicId: priv.VnicId,
})
if err != nil || len(atts.Items) == 0 || atts.Items[0].InstanceId == nil {
return "", ""
}
instanceID := *atts.Items[0].InstanceId
inst, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
if err != nil {
return instanceID, ""
}
return instanceID, deref(inst.DisplayName)
}
// CreateReservedIP 实现 Client:在指定 compartment 创建保留公网 IP(空为生效 compartment)。
func (c *RealClient) CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
details := core.CreatePublicIpDetails{
CompartmentId: hostCompartment(cred, compartmentID),
Lifetime: core.CreatePublicIpDetailsLifetimeReserved,
}
if displayName != "" {
details.DisplayName = &displayName
}
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{CreatePublicIpDetails: details})
if err != nil {
return ReservedIP{}, fmt.Errorf("create reserved ip: %w", err)
}
cc, err := c.computeClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
return c.toReservedIP(ctx, vn, cc, resp.PublicIp), nil
}
// AssignReservedIP 实现 Client:绑定保留 IP 到实例主私有 IP;instanceID 为空表示解绑。
// 目标私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑),地址会变更,调用方需提示用户。
func (c *RealClient) AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
target := common.String("")
if instanceID != "" {
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
target = priv.Id
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: target},
})
if err != nil {
return fmt.Errorf("assign reserved ip: %w", err)
}
return nil
}
// AssignReservedIPToVnic 实现 Client:绑定保留 IP 到指定 VNIC 的主私有 IP(次要网卡场景);
// 该私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑)。
func (c *RealClient) AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: priv.Id},
})
if err != nil {
return fmt.Errorf("assign reserved ip to vnic: %w", err)
}
return nil
}
// vnicPrimaryPrivateIP 取 VNIC 的主私有 IP。
func vnicPrimaryPrivateIP(ctx context.Context, vn core.VirtualNetworkClient, vnicID string) (core.PrivateIp, error) {
resp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: &vnicID})
if err != nil {
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
}
for _, p := range resp.Items {
if p.IsPrimary != nil && *p.IsPrimary {
return p, nil
}
}
return core.PrivateIp{}, fmt.Errorf("assign reserved ip: vnic has no primary private ip")
}
// DeleteReservedIP 实现 Client:删除保留公网 IP(已绑定的会先被 OCI 拒绝)。
func (c *RealClient) DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: &publicIPID}); err != nil {
return fmt.Errorf("delete reserved ip: %w", err)
}
return nil
}
+4
View File
@@ -26,6 +26,9 @@ type ComputeShape struct {
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
Gpus int `json:"gpus,omitempty"`
ProcessorDescription string `json:"processorDescription,omitempty"`
// QuotaNames 是该 shape 对应的配额名(与 Limits 服务 compute limit name 同名),
// 前端据此结合 limits 行判定配额与 AD 可用性。
QuotaNames []string `json:"quotaNames,omitempty"`
}
// shapeCacheEntry 是一份 shape 清单的缓存条目。
@@ -92,6 +95,7 @@ func toComputeShape(s core.Shape) ComputeShape {
BillingType: string(s.BillingType),
IsFlexible: s.OcpuOptions != nil,
ProcessorDescription: deref(s.ProcessorDescription),
QuotaNames: s.QuotaNames,
}
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
if s.Gpus != nil {
+11 -5
View File
@@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy"
// scimPatchSchema 是 SCIM PatchOp 的 schema。
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
// signOnConsentSchema 是修改 Oracle 预置 OCI Console sign-on 策略的知情同意扩展 schema。
const signOnConsentSchema = "urn:ietf:params:scim:schemas:oracle:idcs:extension:ociconsolesignonpolicyconsent:Policy"
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
type SignOnRuleInfo struct {
ID string `json:"id"`
@@ -220,15 +223,18 @@ func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRu
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
var value interface{} = rules
var consent interface{} = true
var justification interface{} = "MFA Configured in External IDP"
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
PolicyId: common.String(ociConsolePolicyID),
PatchOp: identitydomains.PatchOp{
Schemas: []string{scimPatchSchema},
Operations: []identitydomains.Operations{{
Op: identitydomains.OperationsOpReplace,
Path: common.String("rules"),
Value: &value,
}},
// 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent
Operations: []identitydomains.Operations{
{Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value},
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":consent"), Value: &consent},
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":justification"), Value: &justification},
},
},
})
if err != nil {
+60
View File
@@ -760,6 +760,66 @@ func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentia
return deleted, nil
}
// TenantUserApiKey 是用户名下的一把 API 签名 key。
type TenantUserApiKey struct {
Fingerprint string `json:"fingerprint"`
TimeCreated *time.Time `json:"timeCreated"`
// IsCurrent 表示该 key 正被当前配置用于签名请求
IsCurrent bool `json:"isCurrent"`
}
// ListTenantUserApiKeys 实现 Client:列出用户的 API 签名 key。
func (c *RealClient) ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return nil, err
}
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
if err != nil {
return nil, fmt.Errorf("list api keys: %w", err)
}
keys := make([]TenantUserApiKey, 0, len(resp.Items))
for _, item := range resp.Items {
k := TenantUserApiKey{Fingerprint: deref(item.Fingerprint)}
if item.TimeCreated != nil {
t := item.TimeCreated.Time
k.TimeCreated = &t
}
k.IsCurrent = userID == cred.UserOCID && k.Fingerprint == cred.Fingerprint
keys = append(keys, k)
}
return keys, nil
}
// UploadTenantUserApiKey 实现 Client:为用户上传 RSA 公钥,返回 OCI 回填的指纹。
func (c *RealClient) UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return "", err
}
resp, err := ic.UploadApiKey(ctx, identity.UploadApiKeyRequest{
UserId: &userID,
CreateApiKeyDetails: identity.CreateApiKeyDetails{Key: &publicKeyPEM},
})
if err != nil {
return "", fmt.Errorf("upload api key: %w", err)
}
return deref(resp.Fingerprint), nil
}
// DeleteTenantUserApiKey 实现 Client:按指纹删除用户的单把 API key。
func (c *RealClient) DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return err
}
_, err = ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
if err != nil {
return fmt.Errorf("delete api key %s: %w", fingerprint, err)
}
return nil
}
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
+26 -1
View File
@@ -9,7 +9,7 @@ import (
)
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
// 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
@@ -32,6 +32,9 @@ func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID s
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
return err
}
if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil {
return err
}
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
}
@@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com
}
return nil
}
// deleteVcnNsgs 删除 VCN 下全部网络安全组:遗留 NSG 会使 DeleteVcn 报
// 409 associated with NSG。仅按 VcnId 过滤,跨 compartment 的 NSG 也能覆盖;
// 子网删除后 VNIC 已不存在,NSG 不再有关联,可直接删除。
func deleteVcnNsgs(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
resp, err := vn.ListNetworkSecurityGroups(ctx, core.ListNetworkSecurityGroupsRequest{VcnId: &vcnID})
if err != nil {
return fmt.Errorf("list network security groups: %w", err)
}
for _, g := range resp.Items {
if g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminated ||
g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminating {
continue
}
if _, err := vn.DeleteNetworkSecurityGroup(ctx, core.DeleteNetworkSecurityGroupRequest{
NetworkSecurityGroupId: g.Id,
}); err != nil {
return fmt.Errorf("delete network security group %s: %w", deref(g.DisplayName), err)
}
}
return nil
}
+6
View File
@@ -31,6 +31,8 @@ type AttachVnicInput struct {
AssignPublicIP bool `json:"assignPublicIp"`
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
AssignIpv6 bool `json:"assignIpv6"`
// ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。
ReservedPublicIPID string `json:"reservedPublicIpId"`
}
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
@@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i
if err != nil {
return Vnic{}, err
}
// 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := &core.CreateVnicDetails{
SubnetId: &in.SubnetID,
AssignPublicIp: &in.AssignPublicIP,
+328 -32
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -61,20 +62,82 @@ type AiGatewayService struct {
onChannelsChanged func(context.Context)
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
filterDeprecated atomic.Bool
// streamGuard* 是 Responses 流式保险丝(instructions+tools 合计超阈值改非流式)
streamGuardEnabled atomic.Bool
streamGuardKB atomic.Int64
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
grokWebSearch atomic.Bool
grokXSearch atomic.Bool
// upstreamWaitSec 是 responses 直通的上游无响应预算(秒):非流式为单次尝试
// 总超时,流式为等待响应头预算;multi-agent/搜索类模型远超 SDK 默认 60s
upstreamWaitSec atomic.Int64
}
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
var row model.Setting
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
s.filterDeprecated.Store(row.Value == "1")
}
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
s.upstreamWaitSec.Store(int64(loadIntSetting(db, settingAiUpstreamWaitSec, defaultUpstreamWaitSec)))
return s
}
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭
const settingAiFilterDeprecated = "ai_filter_deprecated"
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
const (
// settingAiFilterDeprecated 是「过滤弃用模型」开关,缺省关闭。
settingAiFilterDeprecated = "ai_filter_deprecated"
// settingAiStreamGuardEnabled / settingAiStreamGuardKB 是流式保险丝开关与
// 阈值(KB),缺省开、60(上游对 instructions+tools >≈64.5KB 流式静默断流)。
settingAiStreamGuardEnabled = "ai_stream_guard_enabled"
settingAiStreamGuardKB = "ai_stream_guard_kb"
// settingAiGrokWebSearch / settingAiGrokXSearch 是 grok 搜索工具默认注入
// 开关,缺省开。
settingAiGrokWebSearch = "ai_grok_web_search"
settingAiGrokXSearch = "ai_grok_x_search"
// settingAiUpstreamWaitSec 是 responses 直通的上游无响应预算(秒)。
settingAiUpstreamWaitSec = "ai_upstream_wait_seconds"
)
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
const defaultStreamGuardKB = 60
// defaultUpstreamWaitSec 是上游无响应预算缺省值(秒):multi-agent 非流式
// 实测 100~180s 才回响应头,给足余量;上下限见 SetUpstreamWait。
const defaultUpstreamWaitSec = 300
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
return def
}
return row.Value == "1"
}
// loadIntSetting 读 settings 表整数键,无行或解析失败时返回缺省。
func loadIntSetting(db *gorm.DB, key string, def int) int {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
return def
}
n, err := strconv.Atoi(row.Value)
if err != nil {
return def
}
return n
}
// saveBoolSetting 持久化布尔键。
func (s *AiGatewayService) saveBoolSetting(ctx context.Context, key string, on bool) error {
value := "0"
if on {
value = "1"
}
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
}
// FilterDeprecated 返回「过滤弃用模型」开关状态。
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
@@ -82,18 +145,73 @@ func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.L
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
value := "0"
if on {
value = "1"
}
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
if err != nil {
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
}
s.filterDeprecated.Store(on)
return nil
}
// StreamGuard 返回流式保险丝开关与阈值(KB)。
func (s *AiGatewayService) StreamGuard() (bool, int) {
return s.streamGuardEnabled.Load(), int(s.streamGuardKB.Load())
}
// SetStreamGuard 持久化并即时生效流式保险丝;kb 限定 1..1024。
func (s *AiGatewayService) SetStreamGuard(ctx context.Context, on bool, kb int) error {
if kb < 1 || kb > 1024 {
return fmt.Errorf("流式保险丝阈值须在 1..1024 KB, 收到 %d", kb)
}
if err := s.saveBoolSetting(ctx, settingAiStreamGuardEnabled, on); err != nil {
return fmt.Errorf("保存流式保险丝开关: %w", err)
}
err := s.db.WithContext(ctx).
Save(&model.Setting{Key: settingAiStreamGuardKB, Value: strconv.Itoa(kb)}).Error
if err != nil {
return fmt.Errorf("保存流式保险丝阈值: %w", err)
}
s.streamGuardEnabled.Store(on)
s.streamGuardKB.Store(int64(kb))
return nil
}
// UpstreamWait 返回 responses 直通的上游无响应预算。
func (s *AiGatewayService) UpstreamWait() time.Duration {
return time.Duration(s.upstreamWaitSec.Load()) * time.Second
}
// SetUpstreamWait 持久化并即时生效上游无响应预算;sec 限定 30..900。
func (s *AiGatewayService) SetUpstreamWait(ctx context.Context, sec int) error {
if sec < 30 || sec > 900 {
return fmt.Errorf("上游无响应预算须在 30..900 秒, 收到 %d", sec)
}
err := s.db.WithContext(ctx).
Save(&model.Setting{Key: settingAiUpstreamWaitSec, Value: strconv.Itoa(sec)}).Error
if err != nil {
return fmt.Errorf("保存上游无响应预算: %w", err)
}
s.upstreamWaitSec.Store(int64(sec))
return nil
}
// GrokSearch 返回 grok 服务端搜索工具默认注入开关(web_search, x_search)。
func (s *AiGatewayService) GrokSearch() (bool, bool) {
return s.grokWebSearch.Load(), s.grokXSearch.Load()
}
// SetGrokSearch 持久化并即时生效 grok 搜索工具默认注入开关。
func (s *AiGatewayService) SetGrokSearch(ctx context.Context, web, x bool) error {
if err := s.saveBoolSetting(ctx, settingAiGrokWebSearch, web); err != nil {
return fmt.Errorf("保存 grok web_search 开关: %w", err)
}
if err := s.saveBoolSetting(ctx, settingAiGrokXSearch, x); err != nil {
return fmt.Errorf("保存 grok x_search 开关: %w", err)
}
s.grokWebSearch.Store(web)
s.grokXSearch.Store(x)
return nil
}
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
s.onChannelsChanged = fn
@@ -268,11 +386,34 @@ func valueOr(p *int, def int) int {
return def
}
// Channels 列出全部渠道。
// Channels 列出全部渠道并回填各自的模型缓存计数
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
var chs []model.AiChannel
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
return chs, err
if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil {
return nil, err
}
var rows []struct {
ChannelID uint
N int64
}
q := s.db.WithContext(ctx).Model(&model.AiModelCache{}).
Select("channel_id, COUNT(*) AS n").
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
if s.FilterDeprecated() {
q = q.Where("deprecated_at IS NULL")
}
err := q.Group("channel_id").Scan(&rows).Error
if err != nil {
return nil, err
}
counts := make(map[uint]int64, len(rows))
for _, r := range rows {
counts[r.ChannelID] = r.N
}
for i := range chs {
chs[i].ModelCount = counts[chs[i].ID]
}
return chs, nil
}
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
@@ -315,7 +456,7 @@ func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
// ---- 探测与模型同步 ----
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → 极小 max_tokens 配额试调。
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
@@ -364,13 +505,15 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
return s.probeChat(ctx, cred, ch, models)
}
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需
// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入
// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选
// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
status, detail := "error", "无可试调对话模型"
quotaDetail := ""
errBudget := 3
for _, m := range probeCandidates(models) {
for _, m := range probeCandidates(ch.ProbeModel, models) {
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
switch {
case code == 200 || code == 429:
@@ -378,7 +521,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
case oci.IsModelUnavailable(err):
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
case code == 401 || code == 403 || code == 404:
return "no_quota", truncateErr(oci.CompactError(err))
quotaDetail = truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
default:
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
if errBudget--; errBudget == 0 {
@@ -386,6 +529,9 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
}
}
}
if quotaDetail != "" {
return "no_quota", quotaDetail
}
return status, detail
}
@@ -393,13 +539,18 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
const probeCandidateCap = 8
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的
// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先;
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
var sorted []oci.GenAiModel
func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel {
var pinned, sorted []oci.GenAiModel
for _, m := range models {
if probeModel != "" && m.Name == probeModel {
pinned = append(pinned, m)
continue
}
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
sorted = append(sorted, m)
}
@@ -407,7 +558,7 @@ func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
sort.SliceStable(sorted, func(i, j int) bool {
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
})
return diversifyByVendor(sorted, probeCandidateCap)
return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...)
}
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
@@ -526,12 +677,80 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo
})
}
// channelModels 列出渠道模型缓存;黑名单模型查询层兜底过滤
// (拉黑即删缓存,正常不会残留,防御旧数据 / 并发窗口);
// 「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)。
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
q := s.db.WithContext(ctx).Where("channel_id = ?", channelID).
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
if s.FilterDeprecated() {
q = q.Where("deprecated_at IS NULL")
}
var rows []model.AiModelCache
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
err := q.Order("name ASC").Find(&rows).Error
return rows, err
}
// ChannelModels 列出渠道的模型缓存(名称排序),渠道不存在时报错。
func (s *AiGatewayService) ChannelModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
var n int64
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Count(&n).Error; err != nil {
return nil, err
}
if n == 0 {
return nil, fmt.Errorf("渠道不存在")
}
return s.channelModels(ctx, id)
}
// TestChannelModel 对渠道缓存中的指定模型发极小试调;通过时把该模型设
// 为渠道探测验证模型(此后探测置于候选首位),渠道探测状态不为 ok 时顺带置 ok 并
// 复位熔断;未通过仅返回错误,不改动渠道状态。
func (s *AiGatewayService) TestChannelModel(ctx context.Context, id uint, name string) (*model.AiChannel, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return nil, fmt.Errorf("渠道不存在")
}
var mc model.AiModelCache
err := s.db.WithContext(ctx).Where("channel_id = ? AND name = ?", id, name).First(&mc).Error
if err != nil {
return nil, fmt.Errorf("模型不在该渠道缓存中,请先同步模型")
}
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, mc.ModelOcid, mc.Name)
if code != 200 && code != 429 {
msg := fmt.Sprintf("HTTP %d", code)
if err != nil {
msg = oci.CompactError(err)
}
return nil, fmt.Errorf("测试未通过:%s", truncateErr(msg))
}
return s.adoptProbeModel(ctx, &ch, name)
}
// adoptProbeModel 记录探测验证模型并返回更新后的渠道;
// 状态不为 ok 时一并置 ok 并复位熔断。
func (s *AiGatewayService) adoptProbeModel(ctx context.Context, ch *model.AiChannel, name string) (*model.AiChannel, error) {
updates := map[string]any{"probe_model": name}
if ch.ProbeStatus != "ok" {
updates["probe_status"] = "ok"
updates["probe_error"] = ""
updates["last_probe_at"] = time.Now()
updates["fail_count"] = 0
updates["disabled_until"] = gorm.Expr("NULL")
}
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil {
return nil, err
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.AiChannel
err := s.db.WithContext(ctx).First(&fresh, ch.ID).Error
return &fresh, err
}
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
@@ -560,6 +779,40 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
return list, nil
}
// AggregatedModel 是聚合模型目录条目(设置页黑名单添加弹窗用)。
type AggregatedModel struct {
Name string `json:"name"`
Capability string `json:"capability"`
}
// AggregatedModels 返回启用渠道去重后的模型目录(含能力);空能力归一为 CHAT。
// 与模型列表口径一致:「过滤弃用」开启时弃用模型不出现在目录中。
func (s *AiGatewayService) AggregatedModels(ctx context.Context) ([]AggregatedModel, error) {
q := s.db.WithContext(ctx).
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
if s.FilterDeprecated() {
q = q.Where("ai_model_caches.deprecated_at IS NULL")
}
var rows []model.AiModelCache
if err := q.Order("ai_model_caches.name ASC").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("聚合模型目录: %w", err)
}
seen := map[string]bool{}
out := []AggregatedModel{}
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
cap := r.Capability
if cap == "" {
cap = "CHAT"
}
out = append(out, AggregatedModel{Name: r.Name, Capability: cap})
}
return out, nil
}
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
@@ -795,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
if err != nil || !ok {
return err
}
on, err := contentLogStillOn(tx, entry.KeyID)
if err != nil || !on {
return err
}
return tx.Create(&entry).Error
})
if err != nil {
@@ -802,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
}
}
// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入,
// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。
func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) {
var key model.AiKey
err := tx.First(&key, keyID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil
}
func truncateBody(s string) string {
if len(s) > aiContentBodyLimit {
return s[:aiContentBodyLimit]
@@ -857,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
}
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
// cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限
// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。
var cleanupBatch = 10000
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环,
// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
@@ -866,16 +1142,36 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
}
var total int64
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
log.Printf("%s cleanup count: %v", tag, err)
return
}
if overflow := int(total) - maxRows; overflow > 0 {
var ids []uint
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
if len(ids) > 0 {
s.db.WithContext(ctx).Delete(m, ids)
for overflow := int(total) - maxRows; overflow > 0; {
n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch))
if err != nil {
log.Printf("%s cleanup overflow: %v", tag, err)
return
}
if n == 0 {
return
}
overflow -= n
}
}
// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。
func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) {
var ids []uint
if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
return 0, fmt.Errorf("pluck oldest: %w", err)
}
if len(ids) == 0 {
return 0, nil
}
if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil {
return 0, fmt.Errorf("delete batch: %w", err)
}
return len(ids), nil
}
// Wait 等待后台清理 goroutine 退出。
func (s *AiGatewayService) Wait() { s.wg.Wait() }
+27 -65
View File
@@ -27,27 +27,27 @@ type aiCandidate struct {
modelOcid string
}
// RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 →
// 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
// routeRetry 统一编排「选渠道(priority→加权随机)→ 调用 → 可重试错误换渠道」,
// 整请求上限 3 次并维护熔断;group 非空时只在同分组渠道内路由。
func routeRetry[T any](ctx context.Context, s *AiGatewayService, modelName, group, capability string, once func(*aiCandidate) (T, error)) (T, ChatMeta, error) {
var zero T
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
cand, err := s.pick(ctx, modelName, group, capability, excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
return zero, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
payload, err := s.passthroughOnce(ctx, cand, raw)
out, err := once(cand)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return payload, meta, nil
return out, meta, nil
}
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
return zero, meta, err
}
if penalize {
s.markFailure(ctx, cand.ch.ID)
@@ -56,7 +56,15 @@ func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, mode
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
return zero, meta, lastErr
}
// RespPassthrough 编排一次非流式直通调用。
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
return routeRetry(ctx, s, modelName, group, "CHAT", func(cand *aiCandidate) ([]byte, error) {
return s.passthroughOnce(ctx, cand, raw)
})
}
func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidate, raw []byte) ([]byte, error) {
@@ -64,42 +72,19 @@ func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidat
if err != nil {
return nil, err
}
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw)
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
}
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
return routeRetry(ctx, s, modelName, group, "CHAT", func(cand *aiCandidate) (io.ReadCloser, error) {
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, meta, err
return nil, err
}
stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return stream, meta, nil
}
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
}
if penalize {
s.markFailure(ctx, cand.ch.ID)
}
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
return s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
})
}
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
@@ -221,34 +206,11 @@ func weightedPick(chs []model.AiChannel) model.AiChannel {
return chs[len(chs)-1]
}
// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道(整请求上限 3 次)
// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道。
func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, req.Model, group, "EMBEDDING", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
resp, err := s.embedOnce(ctx, cand, req)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
}
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
}
if penalize {
s.markFailure(ctx, cand.ch.ID)
}
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
return routeRetry(ctx, s, req.Model, group, "EMBEDDING", func(cand *aiCandidate) (*aiwire.EmbeddingsResponse, error) {
return s.embedOnce(ctx, cand, req)
})
}
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
+70
View File
@@ -11,6 +11,8 @@ import (
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"gorm.io/gorm"
)
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
}
}
// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为:
// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。
func TestCleanupTableBatchesOverflow(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
old := cleanupBatch
cleanupBatch = 3
t.Cleanup(func() { cleanupBatch = old })
for i := 0; i < 10; i++ {
if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// maxRows=2:溢出 8 行,需 3 轮批次(3+3+2)
gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test")
var rest []model.AiCallLog
if err := gw.db.Order("id").Find(&rest).Error; err != nil {
t.Fatalf("load rest: %v", err)
}
if len(rest) != 2 {
t.Fatalf("remaining = %d, want 2", len(rest))
}
for _, row := range rest {
if row.ID <= 8 {
t.Errorf("row %d survived, want oldest deleted first", row.ID)
}
}
}
// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥,
// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。
func TestLogContentRechecksKeyInsideTx(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
t.Fatalf("migrate content log: %v", err)
}
future := time.Now().Add(time.Hour)
key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future}
call := model.AiCallLog{Endpoint: "chat"}
if err := gw.db.Create(&key).Error; err != nil {
t.Fatalf("seed key: %v", err)
}
if err := gw.db.Create(&call).Error; err != nil {
t.Fatalf("seed call: %v", err)
}
countIs := func(want int64, note string) {
t.Helper()
var n int64
if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want {
t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want)
}
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"})
countIs(1, "窗口开启时应写入")
// 管理员立即关闭窗口:在途请求携带的旧快照不得再写
if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID).
Update("content_log_until", gorm.Expr("NULL")).Error; err != nil {
t.Fatalf("close window: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"})
countIs(1, "窗口关闭后不得写入")
// 密钥删除后同样拒写
if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil {
t.Fatalf("delete key: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"})
countIs(1, "密钥已删后不得写入")
}
+253 -5
View File
@@ -83,7 +83,7 @@ func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.C
return f.guardOutcome, f.guardErr
}
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
f.passCalls++
f.passRegions = append(f.passRegions, region)
if len(f.passErrs) > 0 {
@@ -96,7 +96,7 @@ func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.C
return f.passPayload, nil
}
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte) (io.ReadCloser, error) {
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
f.passCalls++
f.passRegions = append(f.passRegions, region)
if len(f.passErrs) > 0 {
@@ -453,7 +453,7 @@ func TestProbeCandidates(t *testing.T) {
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
}
got := probeCandidates(models)
got := probeCandidates("", models)
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
t.Errorf("候选排序 = %+v", got)
}
@@ -473,7 +473,7 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
{Ocid: "g1", Name: "xai.grok-4"},
}
got := probeCandidates(models)
got := probeCandidates("", models)
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
}
@@ -489,11 +489,38 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
for i := 0; i < 12; i++ {
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
}
if capped := probeCandidates(many); len(capped) != probeCandidateCap {
if capped := probeCandidates("", many); len(capped) != probeCandidateCap {
t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
}
}
// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。
func TestProbeCandidatesPinsProbeModel(t *testing.T) {
models := []oci.GenAiModel{
{Ocid: "o2", Name: "cohere.command-r-plus"},
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "o4", Name: "xai.grok-4"},
}
tests := []struct {
name string
probeModel string
wantFirst string
wantLen int
}{
{"验证模型置首位", "xai.grok-4", "xai.grok-4", 3},
{"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3},
{"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := probeCandidates(tt.probeModel, models)
if len(got) != tt.wantLen || got[0].Name != tt.wantFirst {
t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst)
}
})
}
}
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
func entityNotFoundErr() stubServiceError {
return stubServiceError{status: 404,
@@ -560,6 +587,128 @@ func TestProbeAuth404StillNoQuota(t *testing.T) {
}
}
// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。
func TestChannelModelsExcludeBlacklist(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
ctx := context.Background()
// 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口
gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"})
rows, err := gw.ChannelModels(ctx, ch.ID)
if err != nil || len(rows) != 0 {
t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err)
}
chs, err := gw.Channels(ctx)
if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 {
t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err)
}
}
// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于
// 渠道模型列表与数量统计(数据保留,展示口径过滤)。
func TestChannelModelsFilterDeprecated(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
dep := time.Now().Add(-24 * time.Hour)
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3",
Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep})
ctx := context.Background()
rows, _ := gw.ChannelModels(ctx, ch.ID)
if len(rows) != 2 {
t.Fatalf("开关关:弃用模型应在列,got %d", len(rows))
}
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
t.Fatalf("SetFilterDeprecated: %v", err)
}
rows, _ = gw.ChannelModels(ctx, ch.ID)
if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" {
t.Errorf("开关开:弃用模型应被过滤,got %+v", rows)
}
chs, _ := gw.Channels(ctx)
if len(chs) != 1 || chs[0].ModelCount != 1 {
t.Errorf("开关开:数量统计应同口径,got %+v", chs)
}
}
// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额:
// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。
func TestProbe403ContinuesToNextCandidate(t *testing.T) {
deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}}
tests := []struct {
name string
seq []probeResult
wantStatus string
}{
{"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"},
{"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"},
{"全部候选 403", []probeResult{deny, deny}, "no_quota"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{
{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"},
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
},
probeSeq: tt.seq,
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
probed, err := gw.ProbeChannel(ctx, ch.ID)
if err != nil || probed.ProbeStatus != tt.wantStatus {
t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus)
}
})
}
}
// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。
func TestChannelModelTest(t *testing.T) {
client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6})
ctx := context.Background()
if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil {
t.Error("缓存外模型应报错")
}
client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil {
t.Error("403 试调应报测试未通过")
}
var still model.AiChannel
gw.db.First(&still, ch.ID)
if still.ProbeStatus != "no_quota" || still.ProbeModel != "" {
t.Errorf("失败不应改动渠道: %+v", still)
}
client.probeCode, client.probeErr = 200, nil
fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct")
if err != nil {
t.Fatalf("TestChannelModel: %v", err)
}
if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" ||
fresh.ProbeError != "" || fresh.FailCount != 0 {
t.Errorf("通过后应写验证模型并置可用: %+v", fresh)
}
// 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间
cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()}
gw.db.Create(cache2)
fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4")
if err != nil || fresh2.ProbeModel != "xai.grok-4" {
t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err)
}
}
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
// 微调基座 400 换渠道重试成功,且不计入熔断失败
client := &gatewayStubClient{
@@ -911,3 +1060,102 @@ func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
t.Errorf("流内容未透传: %s", payload)
}
}
// TestAiRuntimeSettings 断言流式保险丝与 grok 注入开关的缺省值、往返与持久化。
func TestAiRuntimeSettings(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
if on, kb := gw.StreamGuard(); !on || kb != 60 {
t.Fatalf("保险丝缺省应为 开/60, got %v/%d", on, kb)
}
if web, x := gw.GrokSearch(); !web || !x {
t.Fatalf("grok 注入缺省应双开, got %v/%v", web, x)
}
for _, bad := range []int{0, -1, 1025} {
if err := gw.SetStreamGuard(ctx, true, bad); err == nil {
t.Errorf("阈值 %d 应报错", bad)
}
}
if err := gw.SetStreamGuard(ctx, false, 80); err != nil {
t.Fatalf("SetStreamGuard: %v", err)
}
if err := gw.SetGrokSearch(ctx, false, true); err != nil {
t.Fatalf("SetGrokSearch: %v", err)
}
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
if on, kb := gw2.StreamGuard(); on || kb != 80 {
t.Errorf("重建后保险丝应为 关/80, got %v/%d", on, kb)
}
if web, x := gw2.GrokSearch(); web || !x {
t.Errorf("重建后 grok 注入应为 关/开, got %v/%v", web, x)
}
}
// TestAggregatedModelsFilterDeprecated 断言聚合目录与模型列表口径一致:
// 「过滤弃用」开启时弃用模型不出现,关闭时出现;空能力归一为 CHAT。
func TestAggregatedModelsFilterDeprecated(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
dep := time.Now().Add(-24 * time.Hour)
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
if err := gw.db.Create(old).Error; err != nil {
t.Fatalf("seed deprecated cache: %v", err)
}
ctx := context.Background()
items, err := gw.AggregatedModels(ctx)
if err != nil {
t.Fatalf("AggregatedModels: %v", err)
}
if len(items) != 2 || items[0].Capability == "" {
t.Fatalf("开关关:应含弃用模型且能力归一, got %+v", items)
}
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
t.Fatalf("SetFilterDeprecated: %v", err)
}
items, err = gw.AggregatedModels(ctx)
if err != nil {
t.Fatalf("AggregatedModels(过滤): %v", err)
}
if len(items) != 1 || items[0].Name == "meta.llama-old" {
t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
}
}
func TestUpstreamWaitSetting(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{}})
ctx := context.Background()
if got := gw.UpstreamWait(); got != 300*time.Second {
t.Fatalf("缺省上游无响应预算 = %v, 期望 300s", got)
}
tests := []struct {
name string
sec int
wantErr bool
}{
{name: "下界 30 有效", sec: 30},
{name: "上界 900 有效", sec: 900},
{name: "低于下界拒绝", sec: 29, wantErr: true},
{name: "高于上界拒绝", sec: 901, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := gw.SetUpstreamWait(ctx, tt.sec)
if (err != nil) != tt.wantErr {
t.Fatalf("SetUpstreamWait(%d) = %v, wantErr=%v", tt.sec, err, tt.wantErr)
}
if !tt.wantErr && gw.UpstreamWait() != time.Duration(tt.sec)*time.Second {
t.Fatalf("UpstreamWait = %v, 期望 %ds", gw.UpstreamWait(), tt.sec)
}
})
}
// 持久化后新实例应加载已存值(最后一次成功设置为 900)
gw2 := NewAiGatewayService(gw.db, nil, nil)
if got := gw2.UpstreamWait(); got != 900*time.Second {
t.Fatalf("重建服务加载预算 = %v, 期望 900s", got)
}
}
+482 -6
View File
@@ -23,8 +23,9 @@ func respRejectStateful(req aiwire.RespRequest) error {
}
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
// 放行 functionOracle 文档化的服务端工具(web_search / x_search /
// code_interpreter / mcp)
// 放行 functionOracle 文档化的服务端工具(web_search / x_search /
// code_interpreter / mcp)与 codex 特有形态(namespace 工具分组、custom 自由
// 格式,上游不识别,转发前拍平/转换)。
func RespPassthroughValidate(req aiwire.RespRequest) error {
if strings.TrimSpace(req.Model) == "" {
return fmt.Errorf("model 不能为空")
@@ -34,7 +35,7 @@ func RespPassthroughValidate(req aiwire.RespRequest) error {
}
for _, t := range req.Tools {
switch t.Type {
case "function", "web_search", "x_search", "code_interpreter", "mcp":
case "function", "web_search", "x_search", "code_interpreter", "mcp", "namespace", "custom", "tool_search":
default:
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
}
@@ -42,17 +43,492 @@ func RespPassthroughValidate(req aiwire.RespRequest) error {
return nil
}
// RespNsRef 一条拍平映射:限定名对应的原 namespace 与短名。codex 的回程路由
// 依赖 function_call 项上的 name+namespace 双字段,响应侧按此映射还原。
type RespNsRef struct {
Namespace string
Name string
}
// RespCompat 是直通请求 codex 兼容改写的结果:Flattened/Converted/Dropped 供
// 调用方记日志;NsRefs / CustomNames 非空表示响应侧需做工具调用项还原。
type RespCompat struct {
Flattened []string
Converted []string
Dropped []string
NsRefs map[string]RespNsRef
CustomNames map[string]struct{}
}
// NeedRestore 报告响应是否需要做工具调用项还原。
func (c RespCompat) NeedRestore() bool {
return len(c.NsRefs) > 0 || len(c.CustomNames) > 0
}
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
func RespPassthroughBody(raw []byte) ([]byte, error) {
// 同时做 codex 兼容改写(namespace 拍平、多轮历史与 tool_choice 重限定)。
func RespPassthroughBody(raw []byte) ([]byte, RespCompat, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return nil, fmt.Errorf("解析请求体: %w", err)
return nil, RespCompat{}, fmt.Errorf("解析请求体: %w", err)
}
body["store"] = false
return json.Marshal(body)
compat := respCompatTools(body)
respCompatInputCalls(body)
respCompatToolChoice(body)
out, err := json.Marshal(body)
return out, compat, err
}
// respCompatTools 对 tools 做 codex 兼容改写(上游实测行为见任务档案):
// namespace 工具组上游 422,拆平为限定名 function 工具;custom(自由格式)上游
// 同样 422,apply_patch 丢弃(grok 系未训练该补丁格式,失败编辑不如 shell 回退),
// 其余转 function 包装;web_search 的 external_web_access 参数上游 400,false 是
// "仅缓存检索"降权模式,按不越权原则连工具剥离,true 等价默认行为仅删键;
// 剔空后删 tools,并连删 tool_choice 与 parallel_tool_calls(上游拒绝无 tools
// 带 tool_choice)。
func respCompatTools(body map[string]any) RespCompat {
tools, ok := body["tools"].([]any)
if !ok {
return RespCompat{}
}
compat := RespCompat{NsRefs: map[string]RespNsRef{}, CustomNames: map[string]struct{}{}}
kept := make([]any, 0, len(tools))
for _, item := range tools {
tool, _ := item.(map[string]any)
switch {
case tool != nil && tool["type"] == "namespace":
ns, _ := tool["name"].(string)
compat.Flattened = append(compat.Flattened, "namespace:"+ns)
kept = append(kept, respFlattenNsTool(ns, tool, &compat)...)
continue
case tool != nil && tool["type"] == "custom":
name, _ := tool["name"].(string)
if name == "apply_patch" {
compat.Dropped = append(compat.Dropped, "custom:apply_patch")
continue
}
respConvertCustomTool(tool)
if name != "" {
compat.CustomNames[name] = struct{}{}
compat.Converted = append(compat.Converted, "custom:"+name)
}
kept = append(kept, tool)
continue
}
if desc, drop := respToolDrop(tool); drop {
compat.Dropped = append(compat.Dropped, desc)
continue
}
kept = append(kept, item)
}
if len(kept) == 0 {
delete(body, "tools")
delete(body, "tool_choice")
delete(body, "parallel_tool_calls")
} else {
body["tools"] = kept
}
return compat
}
// respConvertCustomTool 把 custom(自由格式)工具改写为 function:补 input 包装
// schema(custom 工具本无 parameters),模型以 {"input": 文本} 形态调用,响应侧
// 按 CustomNames 回转;custom 专有的 format(语法约束)字段一并移除。
func respConvertCustomTool(tool map[string]any) {
tool["type"] = "function"
delete(tool, "format")
if _, ok := tool["parameters"]; !ok {
tool["parameters"] = map[string]any{
"type": "object",
"properties": map[string]any{"input": map[string]any{"type": "string"}},
"required": []any{"input"},
}
}
}
// respFlattenNsTool 把 namespace 组内子工具上提为顶层工具:限定改名、缺
// parameters 补空 schema(上游必填),映射写入 NsRefs 供响应侧还原;非
// function 子工具(如 custom)上游同样不认,剥离并记录。
func respFlattenNsTool(ns string, tool map[string]any, compat *RespCompat) []any {
children, _ := tool["tools"].([]any)
out := make([]any, 0, len(children))
for _, c := range children {
child, _ := c.(map[string]any)
if child == nil {
continue
}
short, _ := child["name"].(string)
if t, _ := child["type"].(string); t != "" && t != "function" {
compat.Dropped = append(compat.Dropped, ns+"."+short+"(type="+t+")")
continue
}
qualified := respQualifyNsName(ns, short)
if qualified == "" {
continue
}
child["name"] = qualified
if _, ok := child["parameters"]; !ok {
child["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
}
compat.NsRefs[qualified] = RespNsRef{Namespace: ns, Name: short}
out = append(out, child)
}
return out
}
// respQualifyNsName 生成拍平后的限定名,与响应侧还原互逆(对齐 CLIProxyAPI):
// mcp__ 开头的子工具名自带全局前缀不再限定;ns 以 __ 结尾直接拼接;已带前缀
// 不重复添加。
func respQualifyNsName(ns, name string) string {
ns, name = strings.TrimSpace(ns), strings.TrimSpace(name)
if ns == "" || name == "" || strings.HasPrefix(name, "mcp__") {
return name
}
prefix := ns
if !strings.HasSuffix(prefix, "__") {
prefix += "__"
}
if strings.HasPrefix(name, prefix) {
return name
}
return prefix + name
}
// respCompatInputCalls 改写多轮历史 input:function_call 的 namespace 字段重限定
// (上游不认识该字段);custom_tool_call / custom_tool_call_output 转换为上游
// 认识的 function_call(_output) 形态(上游 item 变体表不含 custom 系)。
func respCompatInputCalls(body map[string]any) {
input, _ := body["input"].([]any)
for _, it := range input {
item, _ := it.(map[string]any)
if item == nil {
continue
}
switch item["type"] {
case "function_call":
respQualifyCallField(item)
case "custom_tool_call":
item["type"] = "function_call"
item["arguments"] = respCustomCallArguments(item["input"])
delete(item, "input")
case "custom_tool_call_output":
item["type"] = "function_call_output"
item["output"] = respCustomCallOutput(item["output"])
}
}
}
// respCustomCallArguments 把 custom_tool_call 的 input 包装为 function_call 的
// arguments JSON 串(对齐 CLIProxyAPI):input 串本身是 JSON 对象则直用,普通
// 文本包一层 {"input": 文本};对象取序列化;缺失为 {}。与响应侧
// respUnwrapCustomInput 互逆,保证多轮往返无损。
func respCustomCallArguments(input any) string {
switch v := input.(type) {
case string:
trimmed := strings.TrimSpace(v)
var probe map[string]any
if json.Unmarshal([]byte(trimmed), &probe) == nil && probe != nil {
return trimmed
}
enc, err := json.Marshal(v)
if err != nil {
return "{}"
}
return `{"input":` + string(enc) + `}`
case nil:
return "{}"
default:
enc, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(enc)
}
}
// respCustomCallOutput 归一 output 为字符串:非字符串时序列化保底。
func respCustomCallOutput(output any) any {
if _, ok := output.(string); ok {
return output
}
if output == nil {
return ""
}
enc, err := json.Marshal(output)
if err != nil {
return ""
}
return string(enc)
}
// respCompatToolChoice 限定化对象形态的 tool_choice 及其 allowed_tools 列表
// (上游不认 tool_choice 上的 namespace 字段)。
func respCompatToolChoice(body map[string]any) {
tc, _ := body["tool_choice"].(map[string]any)
if tc == nil {
return
}
if tc["type"] == "function" {
respQualifyCallField(tc)
}
list, _ := tc["tools"].([]any)
for _, it := range list {
if sub, _ := it.(map[string]any); sub != nil && sub["type"] == "function" {
respQualifyCallField(sub)
}
}
}
// respQualifyCallField 把一个带 name/namespace 的对象改写为限定名形态。
func respQualifyCallField(m map[string]any) {
ns, _ := m["namespace"].(string)
if strings.TrimSpace(ns) == "" {
return
}
name, _ := m["name"].(string)
if q := respQualifyNsName(ns, name); q != "" {
m["name"] = q
}
delete(m, "namespace")
}
// respToolDrop 判定单个工具是否剥离。web_search 的 external_web_access 参数
// 上游 400:false 表示 OpenAI 的"仅缓存检索"降权模式,上游无对应能力,按不
// 越权原则整个工具剥离;true 等价上游默认行为,仅删键放行。tool_search(codex
// 的工具目录搜索)上游不识别,且 namespace 已全量拍平上送、搜索语义冗余,剥离。
func respToolDrop(tool map[string]any) (string, bool) {
if tool == nil {
return "", false
}
switch tool["type"] {
case "tool_search":
return "tool_search", true
case "web_search":
access, has := tool["external_web_access"]
if !has {
return "", false
}
delete(tool, "external_web_access")
if access == false {
return "web_search(external_web_access=false)", true
}
}
return "", false
}
// RespGuardBytes 返回请求体中 instructions 与 tools 两字段的原始字节数之和,
// 流式保险丝据此判定。上游对二者合计 >≈64.5KB 的流式请求会在推理阶段静默断流
// (纯 EOF,input 正文不计入;2026-07-16 实测仍存在),阈值由设置页 AI Tab 配置。
// 解析失败返回 0(放行,交由上游正常报错)。
func RespGuardBytes(body []byte) int {
var probe struct {
Instructions json.RawMessage `json:"instructions"`
Tools json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return 0
}
return len(probe.Instructions) + len(probe.Tools)
}
// RespInjectGrokTools 为 xai. 前缀模型默认注入服务端搜索工具:开关开启且请求
// tools 中不存在同名工具时追加 {"type":"web_search"} / {"type":"x_search"};
// 已存在(含任意参数形态)不覆盖。返回改写后 body 与注入清单(观测日志用);
// 模型不匹配、两开关全关或解析失败时原样返回。
func RespInjectGrokTools(body []byte, model string, web, x bool) ([]byte, []string) {
if !strings.HasPrefix(model, "xai.") || (!web && !x) {
return body, nil
}
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil {
return body, nil
}
tools, _ := m["tools"].([]any)
missing := map[string]bool{"web_search": web, "x_search": x}
for _, t := range tools {
tool, ok := t.(map[string]any)
if !ok {
continue
}
if typ, _ := tool["type"].(string); missing[typ] {
missing[typ] = false
}
}
var injected []string
for _, typ := range []string{"web_search", "x_search"} {
if missing[typ] {
tools = append(tools, map[string]any{"type": typ})
injected = append(injected, typ)
}
}
if len(injected) == 0 {
return body, nil
}
m["tools"] = tools
out, err := json.Marshal(m)
if err != nil {
return body, nil
}
return out, injected
}
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
// 原样保留。
func RespDisableStream(body []byte) ([]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("解析请求体: %w", err)
}
m["stream"] = false
return json.Marshal(m)
}
// RespSynthSSEEvents 把一份非流式响应合成为最小 SSE 事件序列:created →
// 每个输出项一条 output_item.done → completed。流式升级回退用,客户端拿到
// 完整事件语义但无增量;输出项与 usage 原样承载。
func RespSynthSSEEvents(payload []byte) ([][]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(payload)))
dec.UseNumber()
var resp map[string]any
if err := dec.Decode(&resp); err != nil {
return nil, fmt.Errorf("解析上游响应: %w", err)
}
output, _ := resp["output"].([]any)
status, hadStatus := resp["status"]
resp["status"], resp["output"] = "in_progress", []any{}
created, err := json.Marshal(map[string]any{"type": "response.created", "response": resp})
if err != nil {
return nil, err
}
if hadStatus {
resp["status"] = status
} else {
delete(resp, "status")
}
resp["output"] = output
events := [][]byte{created}
for i, item := range output {
ev, err := json.Marshal(map[string]any{"type": "response.output_item.done", "output_index": i, "item": item})
if err != nil {
return nil, err
}
events = append(events, ev)
}
completed, err := json.Marshal(map[string]any{"type": "response.completed", "response": resp})
if err != nil {
return nil, err
}
return append(events, completed), nil
}
// RespRestoreToolCalls 非流式响应还原:output 数组中命中 NsRefs 的 function_call
// 把限定名还原为短名并补回 namespace 字段(codex 以该双字段路由);命中
// CustomNames 的回转 custom_tool_call(codex 期望 input 字段形态)。无需还原或
// 解析失败时返回原字节,不破坏直通。
func RespRestoreToolCalls(payload []byte, compat RespCompat) []byte {
if !compat.NeedRestore() {
return payload
}
dec := json.NewDecoder(strings.NewReader(string(payload)))
dec.UseNumber()
var body map[string]any
if dec.Decode(&body) != nil {
return payload
}
if !respRestoreOutput(body["output"], compat) {
return payload
}
out, err := json.Marshal(body)
if err != nil {
return payload
}
return out
}
// RespRestoreToolCallsEvent 流式单事件还原:处理事件顶层 item(output_item.*)
// 与 response.output(created/completed 等快照)。返回还原后数据与是否改动;
// 未改动时调用方转发原始行,保持字节级直通。
func RespRestoreToolCallsEvent(data []byte, compat RespCompat) ([]byte, bool) {
if !compat.NeedRestore() {
return data, false
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.UseNumber()
var ev map[string]any
if dec.Decode(&ev) != nil {
return data, false
}
changed := respRestoreCallItem(ev["item"], compat)
if resp, _ := ev["response"].(map[string]any); resp != nil {
changed = respRestoreOutput(resp["output"], compat) || changed
}
if !changed {
return data, false
}
out, err := json.Marshal(ev)
if err != nil {
return data, false
}
return out, true
}
// respRestoreOutput 还原一个 output 数组,返回是否有改动。
func respRestoreOutput(v any, compat RespCompat) bool {
items, _ := v.([]any)
changed := false
for _, it := range items {
if respRestoreCallItem(it, compat) {
changed = true
}
}
return changed
}
// respRestoreCallItem 还原单个 function_call 项,返回是否改动。namespace 还原与
// custom 回转互斥:custom 工具不参与 namespace 拍平。
func respRestoreCallItem(v any, compat RespCompat) bool {
item, _ := v.(map[string]any)
if item == nil || item["type"] != "function_call" {
return false
}
name, _ := item["name"].(string)
if ref, ok := compat.NsRefs[name]; ok {
item["name"] = ref.Name
item["namespace"] = ref.Namespace
return true
}
if _, ok := compat.CustomNames[name]; ok {
item["type"] = "custom_tool_call"
item["input"] = respUnwrapCustomInput(item["arguments"])
delete(item, "arguments")
return true
}
return false
}
// respUnwrapCustomInput 解包 arguments:形如 {"input": 串} 取其 input(非串取该
// 值原文),其余情况整串返回。与请求侧 respCustomCallArguments 互逆。
func respUnwrapCustomInput(arguments any) string {
s, _ := arguments.(string)
var probe map[string]json.RawMessage
if json.Unmarshal([]byte(s), &probe) == nil {
if raw, ok := probe["input"]; ok {
var str string
if json.Unmarshal(raw, &str) == nil {
return str
}
return string(raw)
}
}
return s
}
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
+433 -2
View File
@@ -2,6 +2,8 @@ package service
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
@@ -43,6 +45,9 @@ func TestRespPassthroughValidate(t *testing.T) {
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
{"namespace放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "namespace", Name: "multi_agent_v1"}}}, false},
{"custom放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "custom", Name: "run_script"}}}, false},
{"tool_search放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "tool_search"}}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -56,10 +61,13 @@ func TestRespPassthroughValidate(t *testing.T) {
func TestRespPassthroughBody(t *testing.T) {
raw := []byte(`{"model":"m","input":"hi","stream":true,"store":true,"max_output_tokens":128,"custom_field":{"a":1.5}}`)
out, err := RespPassthroughBody(raw)
out, compat, err := RespPassthroughBody(raw)
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
if len(compat.Dropped) != 0 || len(compat.Flattened) != 0 || len(compat.NsRefs) != 0 {
t.Errorf("无 codex 工具不应有兼容改写: %+v", compat)
}
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
t.Fatalf("unmarshal out: %v", err)
@@ -76,11 +84,206 @@ func TestRespPassthroughBody(t *testing.T) {
if !strings.Contains(string(out), `"custom_field"`) {
t.Errorf("未知字段应保留: %s", out)
}
if _, err := RespPassthroughBody([]byte("not-json")); err == nil {
if _, _, err := RespPassthroughBody([]byte("not-json")); err == nil {
t.Error("非法 JSON 应报错")
}
}
// TestRespQualifyNsName 断言限定名生成规则与 CLIProxyAPI 对齐。
func TestRespQualifyNsName(t *testing.T) {
tests := []struct{ name, ns, tool, want string }{
{"常规拼接", "multi_agent_v1", "spawn_agent", "multi_agent_v1__spawn_agent"},
{"mcp子工具不加前缀", "mcp__sites", "mcp__sites__create_site", "mcp__sites__create_site"},
{"ns以双下划线结尾", "codex_app__", "update", "codex_app__update"},
{"已带前缀不重复", "ns1", "ns1__tool", "ns1__tool"},
{"空ns原样", "", "tool", "tool"},
{"空名返回空", "ns1", "", ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := respQualifyNsName(test.ns, test.tool); got != test.want {
t.Fatalf("respQualifyNsName(%q,%q) = %q, want %q", test.ns, test.tool, got, test.want)
}
})
}
}
// TestRespPassthroughBodyCompatTools 断言 codex 工具兼容改写:namespace 拍平为
// 限定名 function 并建映射,web_search 降权剥离,剔空连删 tool_choice。
func TestRespPassthroughBodyCompatTools(t *testing.T) {
tests := []struct {
name string
raw string
wantFlattened []string
wantDropped []string
wantRefs map[string]RespNsRef
wantContains []string
wantAbsent []string
}{
{
"namespace拍平保留function",
`{"model":"m","tools":[{"type":"function","name":"exec"},{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}]}`,
[]string{"namespace:multi_agent_v1"},
nil,
map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}},
[]string{`"name":"multi_agent_v1__spawn_agent"`, `"name":"exec"`},
[]string{`"type":"namespace"`},
},
{
"mcp子工具保名并补parameters",
`{"model":"m","tools":[{"type":"namespace","name":"mcp__sites","tools":[{"type":"function","name":"mcp__sites__create"}]}]}`,
[]string{"namespace:mcp__sites"},
nil,
map[string]RespNsRef{"mcp__sites__create": {Namespace: "mcp__sites", Name: "mcp__sites__create"}},
[]string{`"name":"mcp__sites__create"`, `"parameters":{"properties":{},"type":"object"}`},
[]string{`"type":"namespace"`},
},
{
"非function子工具剥离",
`{"model":"m","tools":[{"type":"namespace","name":"ns1","tools":[{"type":"custom","name":"patch"},{"type":"function","name":"run"}]}]}`,
[]string{"namespace:ns1"},
[]string{"ns1.patch(type=custom)"},
map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}},
[]string{`"name":"ns1__run"`},
[]string{"patch"},
},
{
"剔空连删tool_choice",
`{"model":"m","tool_choice":"auto","parallel_tool_calls":false,"tools":[{"type":"web_search","external_web_access":false}]}`,
nil,
[]string{"web_search(external_web_access=false)"},
nil,
nil,
[]string{`"tools"`, `"tool_choice"`, `"parallel_tool_calls"`},
},
{
"web_search全访问仅删键",
`{"model":"m","tools":[{"type":"web_search","external_web_access":true}]}`,
nil,
nil,
nil,
[]string{`"tools":[{"type":"web_search"}]`},
[]string{"external_web_access"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out, compat, err := RespPassthroughBody([]byte(test.raw))
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
if !slices.Equal(compat.Flattened, test.wantFlattened) {
t.Errorf("Flattened = %v, want %v", compat.Flattened, test.wantFlattened)
}
if !slices.Equal(compat.Dropped, test.wantDropped) {
t.Errorf("Dropped = %v, want %v", compat.Dropped, test.wantDropped)
}
for q, ref := range test.wantRefs {
if compat.NsRefs[q] != ref {
t.Errorf("NsRefs[%s] = %+v, want %+v", q, compat.NsRefs[q], ref)
}
}
for _, s := range test.wantContains {
if !strings.Contains(string(out), s) {
t.Errorf("应包含 %s: %s", s, out)
}
}
for _, s := range test.wantAbsent {
if strings.Contains(string(out), s) {
t.Errorf("不应包含 %s: %s", s, out)
}
}
})
}
}
// TestRespCompatHistoryAndToolChoice 断言多轮历史 function_call 与对象 tool_choice
// 的 namespace 字段被重限定并删除。
func TestRespCompatHistoryAndToolChoice(t *testing.T) {
raw := `{"model":"m",
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"multi_agent_v1"},
"input":[
{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"c1","arguments":"{}"},
{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"},
{"type":"function_call_output","call_id":"c1","output":"ok"}
],
"tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{}}]}]}`
out, _, err := RespPassthroughBody([]byte(raw))
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
s := string(out)
if !strings.Contains(s, `"tool_choice":{"name":"multi_agent_v1__spawn_agent","type":"function"}`) {
t.Errorf("tool_choice 应限定化: %s", s)
}
if strings.Contains(s, `"namespace":"multi_agent_v1"`) {
t.Errorf("namespace 字段应全部删除: %s", s)
}
if !strings.Contains(s, `"name":"exec"`) {
t.Errorf("无 namespace 的历史项应原样: %s", s)
}
if c := strings.Count(s, "multi_agent_v1__spawn_agent"); c != 3 {
t.Errorf("限定名应出现 3 次(tools/input/tool_choice), got %d: %s", c, s)
}
}
// TestRespRestoreNamespace 断言非流式响应把限定名还原为短名+namespace 双字段。
func TestRespRestoreNamespace(t *testing.T) {
compat := RespCompat{NsRefs: map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}}}
payload := []byte(`{"id":"r1","output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"c1","arguments":"{}"},{"type":"message","content":[]}],"usage":{"total_tokens":7}}`)
out := RespRestoreToolCalls(payload, compat)
s := string(out)
if !strings.Contains(s, `"name":"spawn_agent"`) || !strings.Contains(s, `"namespace":"multi_agent_v1"`) {
t.Errorf("应还原短名并补 namespace: %s", s)
}
if !strings.Contains(s, `"total_tokens":7`) {
t.Errorf("其余字段应保真: %s", s)
}
for name, payload := range map[string][]byte{
"未命中原样": []byte(`{"output":[{"type":"function_call","name":"other","call_id":"c"}]}`),
"非法JSON": []byte(`xx`),
} {
if got := RespRestoreToolCalls(payload, compat); string(got) != string(payload) {
t.Errorf("%s: 应返回原字节, got %s", name, got)
}
}
if got := RespRestoreToolCalls(payload, RespCompat{}); string(got) != string(payload) {
t.Error("无需还原时应返回原字节")
}
}
// TestRespRestoreNamespaceEvent 断言流式事件还原覆盖 item 与 response.output
// 两个位置,无关事件保持未改动。
func TestRespRestoreNamespaceEvent(t *testing.T) {
compat := RespCompat{NsRefs: map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}}}
tests := []struct {
name string
data string
wantChanged bool
wantSub string
}{
{"output_item.done", `{"type":"response.output_item.done","item":{"type":"function_call","name":"ns1__run","call_id":"c1"}}`, true, `"namespace":"ns1"`},
{"completed快照", `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"ns1__run"}],"usage":{"total_tokens":1}}}`, true, `"name":"run"`},
{"文本增量不动", `{"type":"response.output_text.delta","delta":"hi"}`, false, ""},
{"未命中不动", `{"type":"response.output_item.done","item":{"type":"function_call","name":"exec"}}`, false, ""},
{"非法JSON不动", `not-json`, false, ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out, changed := RespRestoreToolCallsEvent([]byte(test.data), compat)
if changed != test.wantChanged {
t.Fatalf("changed = %v, want %v", changed, test.wantChanged)
}
if !changed && string(out) != test.data {
t.Fatalf("未改动应返回原字节: %s", out)
}
if test.wantSub != "" && !strings.Contains(string(out), test.wantSub) {
t.Fatalf("应包含 %s: %s", test.wantSub, out)
}
})
}
}
func TestRespPassthroughUsage(t *testing.T) {
tests := []struct {
name string
@@ -126,3 +329,231 @@ func TestRespStreamCompletedUsage(t *testing.T) {
}
}
}
// TestRespGuardBytes 断言保险丝计量:instructions+tools 原始字节和,缺字段计 0,
// 解析失败返回 0 放行。
func TestRespGuardBytes(t *testing.T) {
for _, tc := range []struct {
name, body string
want int
}{
{"双字段", `{"instructions":"abcd","tools":[{"type":"web_search"}],"input":"xxxxxxxx"}`,
len(`"abcd"`) + len(`[{"type":"web_search"}]`)},
{"仅 instructions", `{"instructions":"abcd"}`, len(`"abcd"`)},
{"均缺失 input 不计", `{"input":"xxxxxxxxxxxxxxxx"}`, 0},
{"解析失败放行", `not-json`, 0},
} {
t.Run(tc.name, func(t *testing.T) {
if got := RespGuardBytes([]byte(tc.body)); got != tc.want {
t.Fatalf("RespGuardBytes() = %d, want %d", got, tc.want)
}
})
}
}
// TestRespInjectGrokTools 断言 grok 搜索工具默认注入:仅 xai. 模型、开关可控、
// 已带同名工具不覆盖、注入清单正确。
func TestRespInjectGrokTools(t *testing.T) {
for _, tc := range []struct {
name, body, model string
web, x bool
wantInjected []string
wantContains []string
}{
{"非 xai 不注入", `{"tools":[]}`, "meta.llama-3.3", true, true, nil, nil},
{"双开无 tools 字段注入两个", `{"model":"xai.grok-4.3"}`, "xai.grok-4.3", true, true,
[]string{"web_search", "x_search"}, []string{`"web_search"`, `"x_search"`}},
{"已带 web_search 只注入 x_search", `{"tools":[{"type":"web_search","filters":{"x":1}}]}`,
"xai.grok-4.3", true, true, []string{"x_search"}, []string{`"filters"`}},
{"开关全关不注入", `{"tools":[]}`, "xai.grok-4.3", false, false, nil, nil},
{"仅开 x_search", `{"tools":[]}`, "xai.grok-4.3", false, true, []string{"x_search"}, nil},
{"解析失败原样", `not-json`, "xai.grok-4.3", true, true, nil, nil},
} {
t.Run(tc.name, func(t *testing.T) {
out, injected := RespInjectGrokTools([]byte(tc.body), tc.model, tc.web, tc.x)
if fmt.Sprint(injected) != fmt.Sprint(tc.wantInjected) {
t.Fatalf("injected = %v, want %v", injected, tc.wantInjected)
}
if len(injected) == 0 && string(out) != tc.body {
t.Fatalf("未注入时应原样返回: %s", out)
}
for _, sub := range append(tc.wantContains, toolTypes(injected)...) {
if !strings.Contains(string(out), sub) {
t.Fatalf("输出缺少 %s: %s", sub, out)
}
}
})
}
}
// toolTypes 把注入清单转为输出应包含的片段断言。
func toolTypes(injected []string) []string {
var out []string
for _, typ := range injected {
out = append(out, `{"type":"`+typ+`"}`)
}
return out
}
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
func TestRespDisableStream(t *testing.T) {
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
if err != nil {
t.Fatalf("RespDisableStream: %v", err)
}
if !strings.Contains(string(out), `"stream":false`) || !strings.Contains(string(out), `"max_output_tokens":64`) {
t.Fatalf("字段不符: %s", out)
}
if _, err := RespDisableStream([]byte("x")); err == nil {
t.Error("非法 JSON 应报错")
}
}
// TestRespSynthSSEEvents 断言合成事件序列:created(in_progress,空 output)→
// 逐项 output_item.done → completed(完整响应)。
func TestRespSynthSSEEvents(t *testing.T) {
payload := []byte(`{"id":"r1","status":"completed","output":[{"type":"reasoning","summary":[]},{"type":"function_call","name":"run","call_id":"c1"}],"usage":{"total_tokens":9}}`)
events, err := RespSynthSSEEvents(payload)
if err != nil {
t.Fatalf("RespSynthSSEEvents: %v", err)
}
if len(events) != 4 {
t.Fatalf("事件数 = %d, want 4", len(events))
}
first := string(events[0])
if !strings.Contains(first, `"type":"response.created"`) || !strings.Contains(first, `"status":"in_progress"`) || !strings.Contains(first, `"output":[]`) {
t.Errorf("created 事件不符: %s", first)
}
if !strings.Contains(string(events[1]), `"type":"response.output_item.done"`) || !strings.Contains(string(events[1]), `"output_index":0`) {
t.Errorf("item.done 事件不符: %s", events[1])
}
if !strings.Contains(string(events[2]), `"name":"run"`) {
t.Errorf("第二项应为 function_call: %s", events[2])
}
last := string(events[3])
if !strings.Contains(last, `"type":"response.completed"`) || !strings.Contains(last, `"status":"completed"`) ||
!strings.Contains(last, `"total_tokens":9`) || !strings.Contains(last, `"name":"run"`) {
t.Errorf("completed 事件不符: %s", last)
}
if _, err := RespSynthSSEEvents([]byte("x")); err == nil {
t.Error("非法 JSON 应报错")
}
}
// TestRespCompatCustomTools 断言 custom 工具转换:apply_patch 丢弃,其余转
// function 并补 input 包装 schema、记入 CustomNames;format 字段移除。
func TestRespCompatCustomTools(t *testing.T) {
raw := `{"model":"m","tools":[
{"type":"custom","name":"apply_patch","description":"edit files"},
{"type":"custom","name":"run_script","description":"run it","format":{"type":"grammar"}},
{"type":"function","name":"exec","parameters":{}}]}`
out, compat, err := RespPassthroughBody([]byte(raw))
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
if !slices.Equal(compat.Dropped, []string{"custom:apply_patch"}) {
t.Errorf("Dropped = %v", compat.Dropped)
}
if !slices.Equal(compat.Converted, []string{"custom:run_script"}) {
t.Errorf("Converted = %v", compat.Converted)
}
if _, ok := compat.CustomNames["run_script"]; !ok {
t.Errorf("CustomNames 缺 run_script: %v", compat.CustomNames)
}
s := string(out)
if strings.Contains(s, "apply_patch") || strings.Contains(s, `"type":"custom"`) || strings.Contains(s, "grammar") {
t.Errorf("apply_patch/custom/format 应消失: %s", s)
}
if !strings.Contains(s, `"required":["input"]`) || !strings.Contains(s, `"input":{"type":"string"}`) {
t.Errorf("run_script 应补 input 包装 schema: %s", s)
}
}
// TestRespCompatCustomHistory 断言 input 历史的 custom_tool_call(_output) 转换
// 与 arguments 包装三分支。
func TestRespCompatCustomHistory(t *testing.T) {
raw := `{"model":"m","input":[
{"type":"custom_tool_call","call_id":"c1","name":"run_script","input":"plain text"},
{"type":"custom_tool_call","call_id":"c2","name":"run_script","input":"{\"a\":1}"},
{"type":"custom_tool_call","call_id":"c3","name":"run_script"},
{"type":"custom_tool_call_output","call_id":"c1","output":"done"},
{"type":"custom_tool_call_output","call_id":"c2","output":{"ok":true}}]}`
out, _, err := RespPassthroughBody([]byte(raw))
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
s := string(out)
if strings.Contains(s, "custom_tool_call") {
t.Fatalf("custom_tool_call 系应全部转换: %s", s)
}
for _, want := range []string{
`"arguments":"{\"input\":\"plain text\"}"`,
`"arguments":"{\"a\":1}"`,
`"arguments":"{}"`,
`"output":"done"`,
`"output":"{\"ok\":true}"`,
} {
if !strings.Contains(s, want) {
t.Errorf("应包含 %s: %s", want, s)
}
}
}
// TestRespRestoreCustomToolCalls 断言响应侧回转:命中 CustomNames 的
// function_call 变 custom_tool_call 且 input 解包;流式事件同样生效。
func TestRespRestoreCustomToolCalls(t *testing.T) {
compat := RespCompat{CustomNames: map[string]struct{}{"run_script": {}}}
payload := []byte(`{"output":[{"type":"function_call","name":"run_script","call_id":"c1","arguments":"{\"input\":\"echo hi\"}"},{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"}]}`)
s := string(RespRestoreToolCalls(payload, compat))
if !strings.Contains(s, `"type":"custom_tool_call"`) || !strings.Contains(s, `"input":"echo hi"`) {
t.Errorf("应回转 custom_tool_call 并解包 input: %s", s)
}
if strings.Contains(s, `"arguments":"{\"input\":\"echo hi\"}"`) {
t.Errorf("arguments 应删除: %s", s)
}
if !strings.Contains(s, `"name":"exec"`) || strings.Count(s, "custom_tool_call") != 1 {
t.Errorf("非转换名不应动: %s", s)
}
ev := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"run_script","arguments":"not-json"}}`)
got, changed := RespRestoreToolCallsEvent(ev, compat)
if !changed || !strings.Contains(string(got), `"input":"not-json"`) {
t.Errorf("非 JSON arguments 应整串作 input: %s", got)
}
}
// TestRespUnwrapCustomInput 断言解包分支:input 键取值、非串取原文、其余整串。
func TestRespUnwrapCustomInput(t *testing.T) {
tests := []struct {
name string
args any
want string
}{
{"包装取input", `{"input":"hello"}`, "hello"},
{"input非串取原文", `{"input":{"x":1}}`, `{"x":1}`},
{"无input键整串", `{"cmd":"ls"}`, `{"cmd":"ls"}`},
{"非JSON整串", "raw text", "raw text"},
{"非串类型空串", 42, ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := respUnwrapCustomInput(test.args); got != test.want {
t.Fatalf("respUnwrapCustomInput(%v) = %q, want %q", test.args, got, test.want)
}
})
}
}
// TestRespCompatToolSearchDrop 断言 tool_search 剥离(拍平后语义冗余,上游不识别)。
func TestRespCompatToolSearchDrop(t *testing.T) {
raw := `{"model":"m","tools":[{"type":"tool_search"},{"type":"function","name":"exec"}]}`
out, compat, err := RespPassthroughBody([]byte(raw))
if err != nil {
t.Fatalf("RespPassthroughBody: %v", err)
}
if !slices.Equal(compat.Dropped, []string{"tool_search"}) {
t.Errorf("Dropped = %v", compat.Dropped)
}
if strings.Contains(string(out), "tool_search") || !strings.Contains(string(out), `"name":"exec"`) {
t.Errorf("tool_search 应剥离且 function 保留: %s", out)
}
}
+23 -2
View File
@@ -7,6 +7,18 @@ import (
"oci-portal/internal/oci"
)
// ChangeVnicPublicIP 更换指定 VNIC 的临时公网 IP,返回新地址(旧保留 IP 自动解绑)。
func (s *OciConfigService) ChangeVnicPublicIP(ctx context.Context, id uint, region, vnicID string) (string, error) {
if vnicID == "" {
return "", fmt.Errorf("change vnic public ip: vnicId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.ChangeVnicPublicIP(ctx, cred, region, vnicID)
}
// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
cred, err := s.credentialsByID(ctx, id)
@@ -46,7 +58,7 @@ func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, i
return s.client.ListInstanceVnics(ctx, cred, region, instanceID)
}
// AttachVnic 为实例附加次要 VNIC。
// AttachVnic 为实例附加次要 VNIC;选了保留 IP 时由后台等网卡就绪后绑定
func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
if instanceID == "" || in.SubnetID == "" {
return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required")
@@ -55,7 +67,16 @@ func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, inst
if err != nil {
return oci.Vnic{}, err
}
return s.client.AttachVnic(ctx, cred, region, instanceID, in)
vnic, err := s.client.AttachVnic(ctx, cred, region, instanceID, in)
if err != nil {
return vnic, err
}
if in.ReservedPublicIPID != "" {
s.goBind(func() {
s.bindReservedIPToVnicWhenReady(cred, region, instanceID, vnic.AttachmentID, in.ReservedPublicIPID)
})
}
return vnic, nil
}
// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。
+13 -5
View File
@@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数;
// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。
type AuditQuery struct {
Region string
Cursor string
Limit int
Q string
}
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头;
// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。
type AuditEventsView struct {
Items []oci.AuditEvent `json:"items"`
Cursor string `json:"cursor,omitempty"`
Exhausted bool `json:"exhausted"`
Items []oci.AuditEvent `json:"items"`
Cursor string `json:"cursor,omitempty"`
Exhausted bool `json:"exhausted"`
ScannedThrough *time.Time `json:"scannedThrough,omitempty"`
}
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
@@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
if err != nil {
return AuditEventsView{}, err
}
if q.Cursor == "" {
cur.Q = oci.SanitizeAuditTerm(q.Q)
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return AuditEventsView{}, err
@@ -63,6 +70,7 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
if res.Cursor != nil {
view.Cursor = encodeAuditCursor(*res.Cursor)
view.ScannedThrough = &res.Cursor.End
}
return view, nil
}
+97 -32
View File
@@ -39,13 +39,19 @@ type authClaims struct {
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)JWT 签发与验证
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)JWT 签发与验证,
// 以及会话落地与管理(session.go)。
type AuthService struct {
db *gorm.DB
jwtSecret []byte
guard *loginGuard
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
revoked *cache.Cache
// seen 是会话最后活跃回写的节流缓存(jti→占位);撤销时清键保证即时生效
seen *cache.Cache
// revokedJti 是定点撤销的负缓存:无容量上限的专用结构,
// 既不与高频 seen 回写争容量,也不存在满载淘汰导致的令牌复活
revokedJti *jtiTombstones
notifier *Notifier
settings *SettingService
@@ -53,6 +59,9 @@ type AuthService struct {
totpMu sync.Mutex
totpPending map[string]pendingTotp // username → setup 暂存密钥
// cleanupWG 追踪会话清理 goroutine;关停时 Wait 保证清理查询已结束
cleanupWG sync.WaitGroup
}
// NewAuthService 组装依赖。
@@ -61,6 +70,8 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
db: db, jwtSecret: []byte(jwtSecret),
guard: newLoginGuard(),
revoked: cache.New(4096),
seen: cache.New(4096),
revokedJti: newJtiTombstones(),
totpPending: map[string]pendingTotp{},
}
}
@@ -109,11 +120,12 @@ func (s *AuthService) createUser(username, password string) error {
return nil
}
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破,
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
key := guardKey(clientIP, username)
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT 并落地会话;
// 按「IP+用户名」滑动窗口防爆破,锁定期内一律 ErrLoginLocked(正确密码同样拒绝);
// 阈值与时长取安全设置。已启用两步验证时:密码通过但缺验证码返回
// ErrTotpRequired(不计失败),验证码错误计入守卫。
func (s *AuthService) Login(ctx context.Context, username, password, totpCode string, meta SessionMeta) (string, time.Time, error) {
key := guardKey(meta.ClientIP, username)
now := time.Now()
sec := securityOf(s.settings)
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
@@ -129,23 +141,24 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
if errors.Is(err, gorm.ErrRecordNotFound) {
// 与密码错误分支对齐耗时,防用户枚举
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
}
return "", time.Time{}, fmt.Errorf("find user: %w", err)
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
}
if user.TotpSecretEnc != "" {
if totpCode == "" {
return "", time.Time{}, ErrTotpRequired
}
if !s.verifyTotp(&user, totpCode) {
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
}
}
s.guard.success(key)
return s.signToken(user.Username, user.TokenVersion)
meta.Method = "password"
return s.signSessionToken(ctx, &user, meta)
}
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
@@ -176,7 +189,13 @@ func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings
})
}
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
// signToken 签发 JWT,返回令牌、过期时间与 jti(会话落地用)。
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, string, error) {
return s.signTokenWithJTI(username, ver, newTokenID())
}
// signTokenWithJTI 为敏感换发沿用会话 jti,使旧/新 JWT 始终指向同一会话。
func (s *AuthService) signTokenWithJTI(username string, ver uint, jti string) (string, time.Time, string, error) {
now := time.Now()
expires := now.Add(tokenTTL)
claims := authClaims{
@@ -184,31 +203,37 @@ func (s *AuthService) signToken(username string, ver uint) (string, time.Time, e
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
ID: jti,
},
Ver: ver,
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
if err != nil {
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
return "", time.Time{}, "", fmt.Errorf("sign token: %w", err)
}
return token, expires, nil
return token, expires, jti, nil
}
// IssueToken 按账号当前令牌版本签发新 JWT;敏感操作递增版本后用它为
// 操作者重签,避免操作者自身会话中断
// IssueToken 按账号当前令牌版本签发新 JWT(不落会话行);
// 需要会话接续的换发场景用 RenewToken
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
user, err := s.findUser(ctx, username)
if err != nil {
return "", time.Time{}, err
}
return s.signToken(user.Username, user.TokenVersion)
token, expires, _, err := s.signToken(user.Username, user.TokenVersion)
return token, expires, err
}
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", username).
return bumpTokenVersionTx(s.db.WithContext(ctx), username)
}
// bumpTokenVersionTx 事务内递增令牌版本:与认证因子写入同事务提交,
// 避免「因子已生效而旧令牌仍有效」的半程状态。
func bumpTokenVersionTx(tx *gorm.DB, username string) error {
err := tx.Model(&model.User{}).Where("username = ?", username).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
if err != nil {
return fmt.Errorf("bump token version: %w", err)
@@ -216,12 +241,28 @@ func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) err
return nil
}
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌
func (s *AuthService) RevokeSessions(ctx context.Context, username string) (string, time.Time, error) {
if err := s.bumpTokenVersion(ctx, username); err != nil {
return "", time.Time{}, err
}
return s.IssueToken(ctx, username)
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌;
// oldToken 非空时其会话行接续到新令牌(当前设备在列表中保持连续)。
// 行锁下复核 proof:请求挂起期间令牌已失效则拒绝,并发敏感操作后到者拒。
func (s *AuthService) RevokeSessions(ctx context.Context, username, oldToken string, meta SessionMeta, proof TokenProof) (string, time.Time, error) {
var token string
var expires time.Time
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil {
return err
}
if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
return err
}
if err := bumpTokenVersionTx(tx, username); err != nil {
return err
}
user.TokenVersion++
token, expires, err = s.renewSessionTx(tx, user, oldToken, meta)
return err
})
return token, expires, err
}
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
@@ -231,9 +272,28 @@ func newTokenID() string {
return hex.EncodeToString(b)
}
// TokenProof 是鉴权时观察到的令牌快照(版本 + jti):敏感事务在用户行锁下
// 复核它仍是账号当前状态,防「请求挂起期间用户撤销 / 注销,恢复后仍完成
// 敏感变更并换出新令牌」的在途绕过。
type TokenProof struct {
Ver uint
Jti string
}
// ErrTokenStale 表示请求令牌在处理期间已失效(版本已递增或会话已撤销);
// api 层映射 401,前端按「发送时令牌 == 当前令牌」决定是否登出。
var ErrTokenStale = errors.New("会话已失效,请重新登录")
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
username, _, err := s.ParseTokenProof(ctx, tokenString)
return username, err
}
// ParseTokenProof 验证令牌并返回用户名与版本 / jti 快照(RequireAuth 与
// 绑定发起点用;快照随后交由敏感事务复核)。
func (s *AuthService) ParseTokenProof(ctx context.Context, tokenString string) (string, TokenProof, error) {
claims := &authClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@@ -242,23 +302,27 @@ func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (strin
return s.jwtSecret, nil
})
if err != nil {
return "", fmt.Errorf("parse token: %w", err)
return "", TokenProof{}, fmt.Errorf("parse token: %w", err)
}
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
return "", errors.New("token revoked")
return "", TokenProof{}, errors.New("token revoked")
}
user, err := s.findUser(ctx, claims.Subject)
if err != nil {
return "", fmt.Errorf("token subject: %w", err)
return "", TokenProof{}, fmt.Errorf("token subject: %w", err)
}
if claims.Ver != user.TokenVersion {
return "", errors.New("token version outdated")
return "", TokenProof{}, errors.New("token version outdated")
}
return claims.Subject, nil
if err := s.checkSession(ctx, claims.ID); err != nil {
return "", TokenProof{}, err
}
return claims.Subject, TokenProof{Ver: claims.Ver, Jti: claims.ID}, nil
}
// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
func (s *AuthService) Logout(tokenString string) {
// Logout 把令牌拉进黑名单直至其自然过期,并标记对应会话行已撤销;
// 无效/已过期令牌直接视为成功(幂等)。
func (s *AuthService) Logout(ctx context.Context, tokenString string) {
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
return s.jwtSecret, nil
@@ -271,6 +335,7 @@ func (s *AuthService) Logout(tokenString string) {
return
}
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
s.revokeSessionByJTI(ctx, claims.Subject, claims.ID, ttl)
}
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
+16 -16
View File
@@ -21,7 +21,7 @@ func newTestAuth(t *testing.T) *AuthService {
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.UserSession{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewAuthService(db, "test-jwt-secret")
@@ -32,7 +32,7 @@ func TestEnsureAdminCreatesUser(t *testing.T) {
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", ""); err != nil {
if _, _, err := auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); err != nil {
t.Errorf("Login after EnsureAdmin: %v", err)
}
}
@@ -45,10 +45,10 @@ func TestEnsureAdminDoesNotResetPassword(t *testing.T) {
if err := auth.EnsureAdmin("admin", "second"); err != nil {
t.Fatalf("EnsureAdmin twice: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "first", "127.0.0.1", ""); err != nil {
if _, _, err := auth.Login(context.Background(), "admin", "first", "", SessionMeta{ClientIP: "127.0.0.1"}); err != nil {
t.Errorf("Login with original password: %v", err)
}
if _, _, err := auth.Login(context.Background(), "admin", "second", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
if _, _, err := auth.Login(context.Background(), "admin", "second", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err)
}
}
@@ -89,7 +89,7 @@ func TestLoginFailures(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := auth.Login(context.Background(), tt.username, tt.password, "127.0.0.1", "")
_, _, err := auth.Login(context.Background(), tt.username, tt.password, "", SessionMeta{ClientIP: "127.0.0.1"})
if !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err)
}
@@ -102,7 +102,7 @@ func TestTokenRoundTrip(t *testing.T) {
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("Login: %v", err)
}
@@ -125,7 +125,7 @@ func TestParseTokenRejectsForged(t *testing.T) {
if err := other.EnsureAdmin("admin", "pass"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
forged, _, err := other.Login(context.Background(), "admin", "pass", "127.0.0.1", "")
forged, _, err := other.Login(context.Background(), "admin", "pass", "", SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("Login: %v", err)
}
@@ -143,21 +143,21 @@ func TestLogoutRevokesToken(t *testing.T) {
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
token, _, err := auth.signToken("admin", 0)
token, _, _, err := auth.signToken("admin", 0)
if err != nil {
t.Fatalf("signToken: %v", err)
}
if _, err := auth.ParseToken(context.Background(), token); err != nil {
t.Fatalf("ParseToken before logout: %v", err)
}
auth.Logout(token)
auth.Logout(context.Background(), token)
if _, err := auth.ParseToken(context.Background(), token); err == nil {
t.Error("ParseToken after logout: got nil error, want revoked")
}
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
auth.Logout(token)
auth.Logout("not.a.token")
fresh, _, err := auth.signToken("admin", 0)
auth.Logout(context.Background(), token)
auth.Logout(context.Background(), "not.a.token")
fresh, _, _, err := auth.signToken("admin", 0)
if err != nil {
t.Fatalf("signToken fresh: %v", err)
}
@@ -174,13 +174,13 @@ func TestTokenVersionInvalidatesOldToken(t *testing.T) {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
old, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("Login: %v", err)
}
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewPassword: "changed-456", CurrentPassword: "pass123",
})
}, proofOf(t, auth.db, "admin"))
if err != nil {
t.Fatalf("UpdateCredentials: %v", err)
}
@@ -203,11 +203,11 @@ func TestRevokeSessions(t *testing.T) {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
old, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("Login: %v", err)
}
fresh, _, err := auth.RevokeSessions(ctx, "admin")
fresh, _, err := auth.RevokeSessions(ctx, "admin", "", SessionMeta{}, proofOf(t, auth.db, "admin"))
if err != nil {
t.Fatalf("RevokeSessions: %v", err)
}

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