Compare commits
27
Commits
79c9e4d9b9
..
v0.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b2820101 | ||
|
|
109c345e5e | ||
|
|
f1914880ac | ||
|
|
a95a8255bf | ||
|
|
8894330eba | ||
|
|
f51fb6c722 | ||
|
|
0614ef22af | ||
|
|
33e92a65e2 | ||
|
|
f40f2a20e8 | ||
|
|
9cfde8b702 | ||
|
|
deea8629e0 | ||
|
|
6cf9465fea | ||
|
|
882eeade1e | ||
|
|
7019d4c5a6 | ||
|
|
18e63d2dbd | ||
|
|
91999205e2 | ||
|
|
cb66567256 | ||
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e | ||
|
|
4e2bab3032 | ||
|
|
e1f8a0539c | ||
|
|
a8bde89b56 | ||
|
|
1da2197a6c |
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.6
|
0.6.10
|
||||||
@@ -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` 可用性布尔,供登录页渲染入口。
|
||||||
@@ -27,3 +27,35 @@ go func() {
|
|||||||
- 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。
|
- 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。
|
||||||
- cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前**。
|
- 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 约束。
|
- 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add` 时 `Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。
|
||||||
|
|
||||||
|
## HTTP 处理器不得同步执行长任务
|
||||||
|
|
||||||
|
**问题**:`POST /tasks/:id/run` 曾在处理器内同步跑完整个任务(AI 探测挂上模型验证后单次 24~90s),前端点击后长时间零反馈;且执行入口无并发防护,双击/与 cron 重叠会真跑两次。
|
||||||
|
|
||||||
|
**约定**(task.go `TriggerTask` 为范例):
|
||||||
|
|
||||||
|
- 可能超过 1~2s 的执行,API 只做「异步触发」:校验存在 → 抢占执行权 → 挂 WaitGroup 的 goroutine 执行 → 立即 202;结果落任务日志/通知,由前端轮询呈现。
|
||||||
|
- 在飞防重用 `map[uint]bool` + 专属 mutex(`beginRun`/`endRun`):手动重复触发返回哨兵错误(API 映射 409),cron 重叠触发静默跳过。执行权的获取必须在触发方同步完成,不能移进 goroutine(否则有双触发窗口)。
|
||||||
|
- 后台执行 goroutine 一律纳入服务的 WaitGroup,`Stop()` 里 `cron.Stop()` 之后追加 `runWG.Wait()` 收尾。
|
||||||
|
|
||||||
|
## 上游 HTTP 超时:流式/长调用禁用 http.Client.Timeout 总超时
|
||||||
|
|
||||||
|
**问题**(2026-07 responses 直通 60s 断流):`http.Client.Timeout` 覆盖单次请求全生命周期(发起→响应头→**body 读完**)。OCI SDK 默认 60s(`common/client.go` defaultTimeout),对 SSE 流式(读 body 无上限)与 multi-agent/搜索类慢模型(>60s 才回响应头)必然掐断;且超时被 `switchable` 判为可重试,还会误伤渠道熔断计数。
|
||||||
|
|
||||||
|
**约定**(genai_responses.go 为范例):
|
||||||
|
|
||||||
|
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
|
||||||
|
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
|
||||||
|
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
|
||||||
|
- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。
|
||||||
|
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
|
||||||
|
|
||||||
|
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
|
||||||
|
|
||||||
|
**问题**:每个 OCI 操作都新建 SDK client,代理路径连带每次 new 一个 `http.Transport`——连接零复用,每请求付整条 TCP+SOCKS5+TLS 握手(经代理 3+ RTT ≈ 1s+);且对象存储每操作先远程取一次 namespace,单条 PAR 删除被放大到 ~2.3s,几千条串行删除撑爆 30 分钟 purge 窗口。用完即弃的 Transport 未设 `IdleConnTimeout`(零值=客户端永不关),空闲连接堆到远端 ~65s 超时才断,代理侧稳态挂着几十条连接。
|
||||||
|
|
||||||
|
**约定**:
|
||||||
|
|
||||||
|
- 代理出站 `http.Client` 一律经 proxyhttp.go 的包级 `proxyClients` 缓存(按 ProxySpec 值复用),不得在调用点新建 per-request Transport;共享实例只可包装(dispatcher wrap),不得改写其字段。连接池参数集中在 `pooledTransport()`:`MaxIdleConnsPerHost` 须 ≥ 批量并发数。
|
||||||
|
- 租户常量(对象存储 namespace 等)在 `RealClient` 用 `sync.Map` 进程内缓存(照 `limitDefs`/`shapes` 模式),不逐操作远程取。
|
||||||
|
- 批量逐条调用统一走 service 层 `forEachConcurrently` + `bulkDeleteWorkers`(16);对应的 SDK 删除请求带 `bulkRetryPolicy`(429/5xx 退避),并发提速与限流保护成对出现,二者缺一不可。
|
||||||
|
|||||||
@@ -1,54 +1,32 @@
|
|||||||
# Database Guidelines
|
# 数据库规范
|
||||||
|
|
||||||
> Database patterns and conventions for this project.
|
> GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。
|
||||||
|
|
||||||
---
|
## 租户级数据删除
|
||||||
|
|
||||||
## Overview
|
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
||||||
|
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
||||||
|
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
||||||
|
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
||||||
|
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
||||||
|
|
||||||
<!--
|
## 大集合谓词用子查询,禁止展开 IN 列表
|
||||||
Document your project's database conventions here.
|
|
||||||
|
|
||||||
Questions to answer:
|
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
|
||||||
- What ORM/query library do you use?
|
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
|
||||||
- How are migrations managed?
|
- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。
|
||||||
- What are the naming conventions for tables/columns?
|
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
||||||
- How do you handle transactions?
|
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 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)
|
## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 列到已有值的结构体字段不会清零
|
|
||||||
|
|
||||||
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
||||||
|
|
||||||
@@ -58,7 +36,7 @@ Questions to answer:
|
|||||||
|
|
||||||
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
|
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
|
||||||
|
|
||||||
### Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
## Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 正确:零写库条件 + 显式 NULL
|
// 正确:零写库条件 + 显式 NULL
|
||||||
@@ -66,3 +44,23 @@ db.Model(&model.AiChannel{}).
|
|||||||
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
|
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")})
|
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 切换签名用户)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后端目录结构
|
# 后端目录结构
|
||||||
|
|
||||||
> Go 后端工程的目录职责与代码组织约定。
|
> Go 后端工程(`oci-portal/`)的目录职责与代码组织约定。
|
||||||
|
|
||||||
## 目录职责
|
## 目录职责
|
||||||
|
|
||||||
@@ -30,3 +30,5 @@ oci-portal/
|
|||||||
- `defer` 紧跟资源获取语句,成对管理释放。
|
- `defer` 紧跟资源获取语句,成对管理释放。
|
||||||
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
|
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
|
||||||
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
|
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
|
||||||
|
- 路由参数:资源 id 可能含 `/` 等 URL 保留字符时(如 OCI PAR id),一律经 query 传递而非路径参数 —— gin 路径段不匹配 `%2F`,会直接 404(2026-07 round6 教训)。
|
||||||
|
- OSP Gateway(账单/发票,`internal/oci/billing.go`):服务只在租户主区域提供,每个请求都带 `ospHomeRegion` 且客户端 `SetRegion` 到主区域 —— 主区域公共名经测活 `HomeRegionKey` + `RegionByKey` 解析,勿用凭据 region 直连;SDK 金额是 `*float32`,输出前经 `money()` 四舍五入抹掉 float64 转换噪音;`PayInvoice` 的 `Email` 是 API 必填(付款回执),service 层校验后透传。
|
||||||
|
|||||||
@@ -31,3 +31,17 @@ func sanitizeURLError(err error) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。
|
**预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。
|
||||||
|
|
||||||
|
## OCI 错误按语义分类,不要只看 HTTP 状态码
|
||||||
|
|
||||||
|
**问题**:OCI 同一状态码承载多种语义,按状态码一刀切会误判。已踩过的案例(GenAI 网关):
|
||||||
|
|
||||||
|
- **404 双义**:`NotAuthorizedOrNotFound`(消息 "Authorization failed or requested resource not found")是**租户级**无权限/无策略;"Entity with key … not found" 是**模型级**——该模型 OCID 在此区域无按需供给。前者应定论渠道无配额,后者应剔除该模型换下一个候选。
|
||||||
|
- **400 微调基座**:"Not allowed to call finetune base model …, use Endpoint: false" 表示模型在该区域仅作微调基座(dedicated 供给),同样是模型级不可用,不是请求参数错误。
|
||||||
|
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
|
||||||
|
|
||||||
|
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
|
||||||
|
|
||||||
|
## 外部响应体必须限长,超限报错而非静默截断
|
||||||
|
|
||||||
|
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# IdP 图标上传与清理契约
|
||||||
|
|
||||||
|
## 1. Scope / Trigger
|
||||||
|
|
||||||
|
- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
|
||||||
|
- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**,
|
||||||
|
孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。
|
||||||
|
|
||||||
|
## 2. Signatures
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
|
||||||
|
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
|
||||||
|
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>
|
||||||
|
|
||||||
|
interface IdpIconUpload {
|
||||||
|
url: string // 写入 IdP iconUrl 的公网地址
|
||||||
|
fileName: string // 仅用于清理的存储标识
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /storage/v1/Images multipart: file + fileName
|
||||||
|
DELETE /storage/v1/Images query: fileName
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Contracts
|
||||||
|
|
||||||
|
- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制;
|
||||||
|
路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。
|
||||||
|
- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成
|
||||||
|
`idp-icon-<32 lowercase hex><lowercase ext>`:既避免并发/迟到响应下同名互相
|
||||||
|
覆盖,又让存储名成为不可猜测的清理凭据。
|
||||||
|
- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg),
|
||||||
|
不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管,
|
||||||
|
传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起
|
||||||
|
(2026-07 曾过度实现完整解码套件后精简)。
|
||||||
|
- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.<allowed ext>`,
|
||||||
|
不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
|
||||||
|
- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon,
|
||||||
|
用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||||||
|
失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。
|
||||||
|
- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP;
|
||||||
|
回滚成功按普通失败,回滚失败/无法确认返回 `201` 和
|
||||||
|
`setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`;
|
||||||
|
内部原因只按 requestId 写服务端日志,不进响应。
|
||||||
|
- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置,
|
||||||
|
`primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。
|
||||||
|
|
||||||
|
## 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
| 条件 | HTTP |
|
||||||
|
| --- | --- |
|
||||||
|
| 缺少 multipart `file`、文件为空或文件名非法 | 400 |
|
||||||
|
| 文件本体超过 1 MiB | 413 |
|
||||||
|
| 扩展名不支持或与文件头魔数不符 | 415 |
|
||||||
|
| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 |
|
||||||
|
| 上游 DELETE 返回 404 | 204 |
|
||||||
|
| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` |
|
||||||
|
| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 |
|
||||||
|
|
||||||
|
## 5. Tests / Verification Required
|
||||||
|
|
||||||
|
- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。
|
||||||
|
- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游;
|
||||||
|
DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。
|
||||||
|
- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。
|
||||||
|
- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支;
|
||||||
|
后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。
|
||||||
|
- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。
|
||||||
|
|
||||||
|
## 6. Wrong vs Correct
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。
|
||||||
|
onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId))
|
||||||
|
|
||||||
|
// Correct:上传时捕获原始元组,清理只按元组走,失败静默。
|
||||||
|
pendingIcon = { ...uploaded, cfgId, domainId }
|
||||||
|
void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {})
|
||||||
|
```
|
||||||
|
|
||||||
|
反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 +
|
||||||
|
卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验——
|
||||||
|
复杂度与威胁模型不匹配,已于 2026-07 精简移除。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Development Guidelines(oci-portal 后端规范)
|
# 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) | 工具链检查与命名规范 | 已填 |
|
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
|
||||||
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
||||||
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
||||||
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 |
|
||||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
| [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/`)
|
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
|
||||||
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
|
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
|
||||||
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
|
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
|
||||||
- [ ] 复用已有封装,不重复造轮子(见 `../guides/code-reuse-thinking-guide.md`)
|
- [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式)
|
||||||
|
|
||||||
## Quality Check
|
## 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)
|
|
||||||
@@ -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()`。
|
- Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()`。
|
||||||
- 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`。
|
- 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`。
|
||||||
- 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。
|
- 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。
|
||||||
|
|
||||||
|
## JSON 联合类型与内容留档保真(2026-07 内容日志失真教训)
|
||||||
|
|
||||||
|
- aiwire 里兼容多形态的联合类型(如 `RespInput` string|数组)**必须成对实现**
|
||||||
|
`UnmarshalJSON` 与 `MarshalJSON`:只写 Unmarshal 时,任何再序列化(内容日志、
|
||||||
|
测试快照)都会退化成 Go 字段名形态(`{"Text":"","Items":[...],"IsArray":true}`),
|
||||||
|
排障者复制它复现会直接 400。参考 `aiwire/openai.go Content` 的保形写法,
|
||||||
|
往返用 table-driven 测试锁定(`aiwire/responses_test.go`)。
|
||||||
|
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
|
||||||
|
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal` 对
|
||||||
|
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
|
||||||
|
|
||||||
|
## 多字段设置接口用字段级 PATCH,不用全量 PUT
|
||||||
|
|
||||||
|
「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。
|
||||||
|
|||||||
@@ -6,6 +6,26 @@
|
|||||||
- 断言失败时输出 `got/want` 对比,便于定位。
|
- 断言失败时输出 `got/want` 对比,便于定位。
|
||||||
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`。
|
- 辅助函数标注 `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 每个连接是独立库
|
## 常见坑:`:memory:` SQLite 每个连接是独立库
|
||||||
|
|
||||||
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。
|
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。
|
||||||
@@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
|
**预防**:测试涉及「后台 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
@@ -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.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.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
- 1.2 Research `[optional · repeatable]`
|
- 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.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
- 1.5 Completion criteria
|
- 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
|
therefore must cover every required step from implementation through
|
||||||
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
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]
|
[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.
|
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.
|
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`.
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
- 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]
|
[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.
|
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:
|
Spawn the research sub-agent:
|
||||||
|
|
||||||
@@ -361,11 +361,11 @@ Spawn the research sub-agent:
|
|||||||
- **Task description**: Research <specific question>
|
- **Task description**: Research <specific question>
|
||||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
- **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]
|
[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]
|
[/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]`
|
#### 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.
|
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.
|
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]
|
[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) | ✅ |
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
| `implement.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) | ✅ |
|
| `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]`
|
#### 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:
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
- **Agent type**: `trellis-implement`
|
- **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
|
- **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:
|
The platform hook/plugin auto-handles:
|
||||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
- 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
|
- 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:
|
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
|
- 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
|
- 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]
|
[Kiro]
|
||||||
|
|
||||||
@@ -526,13 +527,13 @@ The platform prelude auto-handles the context load requirement:
|
|||||||
|
|
||||||
#### 2.2 Quality check `[required · repeatable]`
|
#### 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:
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
- **Agent type**: `trellis-check`
|
- **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
|
- **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:
|
The check agent's job:
|
||||||
- Review code changes against specs
|
- Review code changes against specs
|
||||||
@@ -540,7 +541,7 @@ The check agent's job:
|
|||||||
- Auto-fix issues it finds
|
- Auto-fix issues it finds
|
||||||
- Run lint and typecheck to verify
|
- 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]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
|||||||
+160
@@ -2,6 +2,166 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [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 提升到 16:openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判
|
||||||
|
|
||||||
## [0.5.1]
|
## [0.5.1]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.5.1
|
v0.8.3
|
||||||
|
|||||||
@@ -6,42 +6,53 @@
|
|||||||
|
|
||||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||||
|
|
||||||

|
[](https://github.com/wangdefaa/oci-portal/releases/latest)
|
||||||

|
[](go.mod)
|
||||||

|
[](docker-compose.yml)
|
||||||

|
[](LICENSE)
|
||||||
|
|
||||||
[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||||
|
|
||||||
|
[AI 网关文档](docs/AI网关.md) · [OCI 调用者指纹评估](docs/OCI调用者指纹评估.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
|
|
||||||
|
|
||||||
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
| 总览 | 登录 |
|
<p align="center">
|
||||||
| --- | --- |
|
<img src="docs/assets/screenshot-overview.png" width="960" alt="OCI Portal 总览">
|
||||||
|  |  |
|
</p>
|
||||||
|
|
||||||
| 租户 | 任务 |
|
<details>
|
||||||
| --- | --- |
|
<summary><strong>展开更多界面截图</strong></summary>
|
||||||
|  |  |
|
|
||||||
|
|
||||||
| AI 网关 | 通知设置 |
|
| 登录 | 租户 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
| 任务 | AI 网关 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| 通知设置 |
|
||||||
|
| --- |
|
||||||
|
|  |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥和口令使用 AES-256-GCM 加密落库
|
| 能力域 | 覆盖范围 |
|
||||||
- **计算、网络与存储**:实例创建与电源操作、公网 IP、IPv6、VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额和成本查询
|
| --- | --- |
|
||||||
- **自动化任务**:抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知
|
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象、在线预览编辑、PAR 直传分享)与限额查询 |
|
||||||
- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
|
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
|
||||||
- **身份与审计**:IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
- **通知与安全**:Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-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。
|
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
|
||||||
|
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
|
||||||
|
|
||||||
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,19 +96,27 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 准备数据目录并启动:
|
2. 准备数据目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。
|
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
|
||||||
|
Docker Desktop 用户通常不需要执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
sudo chown 65532:65532 data
|
sudo chown 65532:65532 data
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动并检查状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 查看初始管理员密码并登录:
|
4. 查看初始管理员密码并登录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep '^ADMIN_PASSWORD=' .env
|
grep '^ADMIN_PASSWORD=' .env
|
||||||
@@ -99,7 +124,14 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
||||||
|
|
||||||
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。
|
启动异常时查看最近日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=100 oci-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
|
||||||
|
|
||||||
### 二进制运行
|
### 二进制运行
|
||||||
|
|
||||||
@@ -107,6 +139,8 @@ Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
||||||
|
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/SHA256SUMS
|
||||||
|
grep 'oci-portal-server-linux-amd64$' SHA256SUMS | sha256sum -c -
|
||||||
chmod +x oci-portal-server-linux-amd64
|
chmod +x oci-portal-server-linux-amd64
|
||||||
|
|
||||||
# 复用上文生成并妥善保存的 .env。
|
# 复用上文生成并妥善保存的 .env。
|
||||||
@@ -142,7 +176,9 @@ CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
|
|||||||
|
|
||||||
## 生产部署
|
## 生产部署
|
||||||
|
|
||||||
服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。
|
> [!WARNING]
|
||||||
|
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
|
||||||
|
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
|
||||||
|
|
||||||
### Caddy
|
### Caddy
|
||||||
|
|
||||||
@@ -229,29 +265,31 @@ OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`do
|
|||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
| 变量 | 必填 | 默认值 | 说明 |
|
| 变量 | 使用条件 | 默认值 | 说明 |
|
||||||
| --- | :---: | --- | --- |
|
| --- | :---: | --- | --- |
|
||||||
| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||||
| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
|
||||||
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
|
||||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||||
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
||||||
| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
| `DB_DSN` | MySQL / PostgreSQL | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
||||||
| `PUBLIC_URL` | 否 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
| `PUBLIC_URL` | 可选 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
||||||
| `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
| `TZ` | 可选 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
||||||
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 可选 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
||||||
| `SWAGGER` | 否 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
| `SWAGGER` | 可选 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
||||||
| `GIN_MODE` | 否 | `release` | `debug` / `release` |
|
| `GIN_MODE` | 可选 | `release` | `debug` / `release` |
|
||||||
|
|
||||||
## 升级与备份
|
## 升级与备份
|
||||||
|
|
||||||
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
|
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
|
||||||
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
|
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
|
||||||
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
|
`data/oci-portal.db`,避免在线复制产生不一致快照。
|
||||||
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
|
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
|
||||||
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
|
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
|
||||||
|
会自动完成数据库迁移。
|
||||||
|
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -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)
|
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
+7
-2
@@ -31,7 +31,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// @title OCI Portal API
|
// @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)。
|
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
||||||
// @BasePath /
|
// @BasePath /
|
||||||
// @securityDefinitions.apikey BearerAuth
|
// @securityDefinitions.apikey BearerAuth
|
||||||
@@ -110,6 +110,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
ociClient := oci.NewCachedClient(oci.NewClient())
|
ociClient := oci.NewCachedClient(oci.NewClient())
|
||||||
ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
|
ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
|
||||||
|
defer ociConfigs.Stop()
|
||||||
settings := service.NewSettingService(db, cipher)
|
settings := service.NewSettingService(db, cipher)
|
||||||
settings.SetEnvPublicURL(cfg.PublicURL)
|
settings.SetEnvPublicURL(cfg.PublicURL)
|
||||||
if err := settings.ReloadSecurity(context.Background()); err != nil {
|
if err := settings.ReloadSecurity(context.Background()); err != nil {
|
||||||
@@ -118,17 +119,21 @@ func run() error {
|
|||||||
notifier := service.NewNotifier(settings)
|
notifier := service.NewNotifier(settings)
|
||||||
auth.SetNotifier(notifier, settings)
|
auth.SetNotifier(notifier, settings)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
|
passkeys := service.NewPasskeyService(db, settings, auth)
|
||||||
|
wallets := service.NewWalletService(db, settings, auth)
|
||||||
systemLogs := service.NewSystemLogService(db)
|
systemLogs := service.NewSystemLogService(db)
|
||||||
logEvents := service.NewLogEventService(db)
|
logEvents := service.NewLogEventService(db)
|
||||||
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
|
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
|
||||||
logEvents.SetNotifier(notifier, settings)
|
logEvents.SetNotifier(notifier, settings)
|
||||||
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
|
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
|
||||||
systemLogs.StartCleanup(cleanupCtx)
|
systemLogs.StartCleanup(cleanupCtx)
|
||||||
|
auth.StartSessionCleanup(cleanupCtx)
|
||||||
logEvents.StartParser(cleanupCtx)
|
logEvents.StartParser(cleanupCtx)
|
||||||
logEvents.StartCleanup(cleanupCtx)
|
logEvents.StartCleanup(cleanupCtx)
|
||||||
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
|
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
|
||||||
aiGateway.StartCleanup(cleanupCtx)
|
aiGateway.StartCleanup(cleanupCtx)
|
||||||
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
|
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
|
||||||
|
defer auth.WaitSessionCleanup()
|
||||||
defer logEvents.Wait()
|
defer logEvents.Wait()
|
||||||
defer systemLogs.Wait()
|
defer systemLogs.Wait()
|
||||||
defer notifier.Wait()
|
defer notifier.Wait()
|
||||||
@@ -149,7 +154,7 @@ func run() error {
|
|||||||
defer proxies.Wait()
|
defer proxies.Wait()
|
||||||
// 「关于」页存储指标需知数据库形态
|
// 「关于」页存储指标需知数据库形态
|
||||||
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
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)
|
return serveHTTP(cfg.Addr, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+498
-195
@@ -1,294 +1,597 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
# AI 网关
|
# AI 网关
|
||||||
|
|
||||||
> 本文是 OCI Portal AI 网关的完整使用与兼容性文档:端点定位、协议兼容边界、已知上游限制与字段兼容矩阵。
|
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
|
||||||
> 路由与鉴权的机器可读定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 表达的兼容边界以本文为准。
|
|
||||||
|
|
||||||
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
| 端点 | 定位 | 流式 |
|
[快速接入](#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 |
|
| Responses 流式保险丝 | 开启;阈值 `60 KB`,按 `instructions` 与 `tools` 两个字段的原始 JSON 值计算 | **设置 → AI → 流式保险丝** |
|
||||||
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE |
|
| Grok 服务端搜索 | 对 `xai.` 模型默认注入 `web_search` 与 `x_search`;同名工具不重复覆盖 | **设置 → AI → Grok 服务端搜索工具** |
|
||||||
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
| 弃用模型过滤 | 开启后从模型列表、请求路由与探测候选中统一排除 | **设置 → AI → 模型治理** |
|
||||||
| `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 Rerank,Jina 风格协议) | 否 |
|
|
||||||
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
|
|
||||||
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
|
||||||
|
|
||||||
兼容边界:
|
<a id="codex"></a>
|
||||||
|
|
||||||
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
|
## Codex 接入
|
||||||
- 网关不保存会话历史,客户端需要携带完整上下文;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 形态,该能力在上游侧不可用
|
|
||||||
|
|
||||||
### 已知上游限制:大 system 区流式断流
|
### 主配置
|
||||||
|
|
||||||
实测(2026-07-13)OCI 兼容面对 `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` 与降级标记
|
[model_providers.oci]
|
||||||
- Responses 直通因初始事件已转发、协议上无法透明降级,调用日志记「上游流提前终止」,客户端需自行回退非流式
|
name = "oci-portal"
|
||||||
- 应急规避:将超长 system 内容移入首条 user 消息正文可绕过该限制(正文不计入),但语义有别,根治有待上游修复
|
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>
|
<a id="compat-responses"></a>
|
||||||
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
|
|
||||||
|
|
||||||
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
|
### OpenAI Responses
|
||||||
|
|
||||||
|
**`POST /ai/v1/responses` · 无状态主接口**
|
||||||
|
|
||||||
|
网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
|
||||||
|
Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>核心字段</strong></summary>
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
| --- | :---: | --- |
|
| --- | :---: | --- |
|
||||||
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
|
| `model` | ◐ | 必填;通过密钥白名单并匹配可用渠道后送往上游 |
|
||||||
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
|
| `input` | ➡️ | 支持字符串或 item 数组;普通内容保留,Codex 工具历史项可能改写 |
|
||||||
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
|
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后保留,不做范围或模型能力校验 |
|
||||||
| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
|
| `temperature`、`top_p` | ➡️ | 保留,不做取值范围校验 |
|
||||||
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
|
| `parallel_tool_calls` | ◐ | 通常保留;若全部工具被剥离,则与 `tool_choice` 一并删除 |
|
||||||
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
|
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个对象保留;是否生效由 OCI 模型决定 |
|
||||||
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
|
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||||
|
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
| `stream` | ✅ | 普通请求、`function` 与服务端工具均支持 SSE |
|
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||||
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||||
| `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)
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 |
|
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制 |
|
||||||
| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` |
|
| `messages` | 🔄 | 必填,转换为 Responses `input` / `instructions` |
|
||||||
| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 |
|
| `system` / `developer` 消息 | ◐ | 文本按顺序合并为 `instructions`;块数组中的非文本内容忽略 |
|
||||||
| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` |
|
| `user` / `assistant` 文本 | 🔄 | 字符串和 `text` 块分别转为 `input_text` / `output_text` |
|
||||||
| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 |
|
| `image_url` | ◐ | URL 或 data URI 转为 `input_image`;`detail` 忽略 |
|
||||||
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
|
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output` |
|
||||||
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||||
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||||
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 |
|
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||||
| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]`;上游断流且尚无输出时自动降级非流式重做,结果按 chunk 序列一次推送 |
|
| `stream` | 🔄 | Responses SSE 转为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
|
||||||
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 |
|
| `stream_options.include_usage` | 🔄 | 控制终块后的独立 usage 块 |
|
||||||
| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 |
|
| `tools[].type=function` | ◐ | 支持 `name`、`description`、`parameters`;`strict` 忽略 |
|
||||||
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
|
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function |
|
||||||
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
|
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format` |
|
||||||
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
|
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort` |
|
||||||
| `store` | 🔄 | 客户端取值被忽略,转换后的上游请求始终使用 `store:false` |
|
| `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`
|
- 非流式固定生成一个 `choices[0]`;文本合并,函数调用转为 `tool_calls`
|
||||||
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留
|
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`
|
||||||
- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
- reasoning、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||||
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens`
|
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 与
|
||||||
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
|
`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>
|
<details>
|
||||||
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
|
<summary><strong>展开顶层字段矩阵</strong></summary>
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
| --- | :---: | --- |
|
| --- | :---: | --- |
|
||||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
|
| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
|
||||||
| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI |
|
| `max_tokens` | 🔄 | 缺省或 ≤0 时使用 8192,再转为 `max_output_tokens` |
|
||||||
| `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` |
|
|
||||||
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||||
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 |
|
| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
|
||||||
| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
|
| `temperature`、`top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||||
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列;上游断流且尚无输出时自动降级非流式重做,结果按事件序列一次推送 |
|
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
|
||||||
| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
|
| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
|
||||||
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 |
|
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool` |
|
||||||
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
|
| `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` |
|
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||||
| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 |
|
| `image` | ◐ | 仅支持 `base64` 与 `url` source |
|
||||||
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 |
|
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称与输入 |
|
||||||
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 |
|
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 与非文本结果丢失 |
|
||||||
| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 |
|
| `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`
|
- 仅将 Responses `output_text` 转为 `text`,`function_call` 转为 `tool_use`
|
||||||
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null`
|
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`
|
||||||
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
|
- 不生成 Anthropic thinking / signature 内容块
|
||||||
- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
|
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||||
- 流式输出标准事件骨架,但不生成 `thinking_delta` 和 `signature_delta`;上游错误事件与无终态断流会转成 Anthropic `error` 事件
|
- 流式上游错误与无终态断流会转成 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>
|
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||||
<summary><code>POST /ai/v1/audio/speech</code> 对比 OpenAI Audio Speech</summary>
|
|
||||||
|
|
||||||
Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除缺省注入 `language` 外不重建请求体,未知字段原样透传。
|
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
| --- | :---: | --- |
|
| --- | :---: | --- |
|
||||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `TTS` 能力的渠道(当前上游仅 `xai.grok-tts`) |
|
| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
|
||||||
| `input` | ◐ | 必填非空,随后原样透传 |
|
| 字符串 `input` | ✅ | 包装为单个输入后调用 OCI |
|
||||||
| `voice` | ➡️ | 原样透传;取 xAI Grok Voice 音色(`ara` / `eve` / `leo` / `rex` / `sal`),OpenAI 标准音色名不可用 |
|
| 字符串数组 `input` | ✅ | 按原顺序调用 OCI |
|
||||||
| `response_format` | ➡️ | 原样透传;实测 `mp3` 可用,其余格式由上游决定 |
|
| `dimensions` | ◐ | 映射为 OCI 输出维度,不校验范围或模型能力 |
|
||||||
| `language`(扩展字段) | 🔄 | 上游必填;客户端缺省时网关自动注入 `"auto"` |
|
| `encoding_format=float` | ✅ | 返回 float 数组;省略时相同 |
|
||||||
| `speed`、`instructions` 及其他未知字段 | ➡️ | 原样透传(含 xAI 专属参数如 `output_format`),是否生效由上游决定 |
|
|
||||||
|
|
||||||
不支持:流式音频(`stream_format` 等流式选项无效,响应恒为一次性完整音频)与 WebSocket 语音会话。
|
不支持(返回 400):token ID 数组、空数组、`null` 和
|
||||||
|
`encoding_format=base64`。`user` 与其他未知字段静默忽略。
|
||||||
|
|
||||||
响应边界:
|
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
<a id="compat-audio"></a>
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道
|
|
||||||
|
|
||||||
实现依据:[`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>
|
#### OpenAI Audio Speech
|
||||||
<summary><code>POST /ai/v1/tts</code> 对比 xAI TTS</summary>
|
|
||||||
|
|
||||||
`/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` |
|
| `model` | ◐ | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
|
||||||
| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 |
|
| `input` | ◐ | 必填非空,随后保留 |
|
||||||
| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) |
|
| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
|
||||||
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) |
|
| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
|
||||||
| `speed` | ➡️ | 原样透传,实测接受 |
|
| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
|
||||||
| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 |
|
| `speed`、`instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||||
| `optimize_streaming_latency`、`text_normalization`、`with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 |
|
|
||||||
|
|
||||||
不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。
|
#### xAI TTS
|
||||||
|
|
||||||
响应边界:
|
**`POST /ai/v1/tts`**
|
||||||
|
|
||||||
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech),
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`)
|
转换为 OpenAI 兼容形态后进入同一上游。
|
||||||
|
|
||||||
实现依据:[`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`)。
|
|
||||||
|
|
||||||
| 字段 | 状态 | 网关行为 |
|
| 字段 | 状态 | 网关行为 |
|
||||||
| --- | :---: | --- |
|
| --- | :---: | --- |
|
||||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `RERANK` 能力的渠道 |
|
| `text` | 🔄 | 必填非空,转换为 `input` |
|
||||||
| `query` | ✅ | 必填非空 |
|
| `language` | ◐ | 必填,接受 BCP-47 或 `auto` |
|
||||||
| `documents` | ◐ | 必填,仅接受字符串数组;Cohere 旧版 `{"text": ...}` 对象数组形态返回 400 |
|
| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
|
||||||
| `top_n` | ✅ | 可选,传给上游限制返回条数;缺省返回全部文档的重排结果 |
|
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
|
||||||
| `return_documents` | ✅ | 可选,`true` 时 `results[].document.text` 回带原文 |
|
| `speed` | ➡️ | 保留,实测可用 |
|
||||||
|
| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
|
||||||
|
| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||||
|
|
||||||
静默忽略:`max_tokens_per_doc` 等 Cohere 专属参数及其他未知字段。
|
共同响应边界:
|
||||||
|
|
||||||
响应边界:
|
- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
|
||||||
|
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||||
- `results[]` 按相关度降序,`index` 指向入参 `documents` 下标,`relevance_score` 为 0–1 浮点;响应 `model` 回显请求值
|
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道
|
- 无 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>
|
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||||
<summary><code>POST /ai/v1/moderations</code> 对比 OpenAI Moderations</summary>
|
|
||||||
|
|
||||||
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` | ✅ | 单条审核 |
|
||||||
| `input` 为字符串数组 | ✅ | 逐条审核,单次 1~8 条 |
|
| 字符串数组 `input` | ✅ | 逐条审核,单次 1~8 条 |
|
||||||
|
|
||||||
不支持(返回 400):多模态 input(图片等对象数组形态)、空字符串条目、空数组或超过 8 条的数组。静默忽略:`model`(接受任意值,不校验白名单)及其他未知字段。
|
不支持(返回 400):多模态 input、空字符串条目、空数组或超过 8 条。
|
||||||
|
`model` 接受但忽略,不参与白名单判断。
|
||||||
|
|
||||||
响应边界:
|
响应边界:
|
||||||
|
|
||||||
- `categories` / `category_scores` 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`,而非 OpenAI 标准类目(`hate` / `violence` 等);任一维度得分 ≥ 0.5 判定 `flagged`
|
- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
|
||||||
- PII 命中放扩展字段 `results[].pii`(`text` / `label` / `score` / `offset` / `length`),不参与 `flagged` 判定;实测中文人名 / 手机号识别较弱,英文 PII 识别正常
|
- 任一维度得分 ≥ 0.5 时 `flagged=true`
|
||||||
|
- PII 命中放在扩展字段 `results[].pii`,包含 `text`、`label`、`score`、
|
||||||
|
`offset` 与 `length`,不参与 `flagged`
|
||||||
|
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||||
- 响应 `model` 恒为 `oci-guardrails`
|
- 响应 `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)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
|
# OCI 调用者指纹评估
|
||||||
|
|
||||||
|
**OCI Portal 请求暴露面与多租户关联风险**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[结论速览](#summary) · [请求暴露面](#request-signals) · [项目指纹](#portal-signals) · [关联评估](#assessment) · [核验依据](#evidence)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本文面向架构与隐私评估,说明 OCI Portal 调用 OCI Public API 时 Oracle
|
||||||
|
> 可见的信息及跨租户关联风险。结论基于当前源码,不代表 Oracle 实际采用的
|
||||||
|
> 风控规则。
|
||||||
|
|
||||||
|
<a id="summary"></a>
|
||||||
|
|
||||||
|
## 结论速览
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> OCI Portal 的每次 API Key 请求都会在签名的 `keyId` 中携带
|
||||||
|
> `tenancy OCID / user OCID / fingerprint`。这能准确识别云身份,但不能单凭
|
||||||
|
> 一次请求证明调用方是 OCI Portal。
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| Oracle 能识别单次请求的租户和用户吗? | **能。**签名身份、源 IP、目标服务、操作与时间均可见 |
|
||||||
|
| 通用 SDK 请求头能识别 OCI Portal 吗? | **不能。**默认 User-Agent、签名算法和 Go TLS 特征被大量客户端共享 |
|
||||||
|
| Oracle 能关联多个租户共用同一实例吗? | **具备能力。**需组合出口 IP、资源元数据、固定文本和调用序列 |
|
||||||
|
| 外部第三方能完成同等关联吗? | **通常不能。**其无法取得 Oracle 网关日志和租户内部资源元数据 |
|
||||||
|
|
||||||
|
<a id="request-signals"></a>
|
||||||
|
|
||||||
|
## 单次请求暴露面
|
||||||
|
|
||||||
|
当前项目统一通过 `common.NewRawConfigurationProvider` 使用 API Key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Signature version="1",
|
||||||
|
keyId="<tenancyOCID>/<userOCID>/<fingerprint>",
|
||||||
|
algorithm="rsa-sha256", ...
|
||||||
|
```
|
||||||
|
|
||||||
|
| 信号 | 默认形态 | 区分度 |
|
||||||
|
| --- | --- | :---: |
|
||||||
|
| 签名身份 | `tenancy / user / fingerprint` 明文位于 `keyId` | 身份强,软件弱 |
|
||||||
|
| User-Agent | 大多数 SDK 请求为 `Oracle-GoSDK/<版本> (...)` | 弱 |
|
||||||
|
| SDK 客户端信息 | SDK 生成的服务请求通常带 `opc-client-info: Oracle-GoSDK/<版本>` | 弱 |
|
||||||
|
| 请求元数据 | `Date`、`Host`、`request-target`;写请求另含 body 摘要 | 通用 |
|
||||||
|
| 网络特征 | 源 IP、TLS / ALPN、HTTP/2 行为 | 单次弱,跨租户组合后增强 |
|
||||||
|
| OCI 遥测头 | 显式启用 `OCI_INCLUDE_REQUEST_TELEMETRY_DATA=true` 时,SDK 操作可写 service / operation;直通请求未必携带 | 可选 |
|
||||||
|
|
||||||
|
项目没有构造 Instance Principal、Resource Principal 或 Session Token
|
||||||
|
鉴权,也没有主动覆盖 User-Agent 或为 `opc-request-id` 设置固定前缀。
|
||||||
|
`opc-client-info` 由 SDK 请求构造器写入,不含 OCI Portal 标记;手工构造的
|
||||||
|
请求可能不带该字段。少量手工签名请求绕过 `BaseClient.prepareRequest`,
|
||||||
|
使用 Go `net/http` 的默认 User-Agent。
|
||||||
|
|
||||||
|
<a id="portal-signals"></a>
|
||||||
|
|
||||||
|
## OCI Portal 增量指纹
|
||||||
|
|
||||||
|
| 信号 | 当前行为 | 关联强度 |
|
||||||
|
| --- | --- | :---: |
|
||||||
|
| 出口 IP | 无租户级或全局代理时,走部署环境默认出口 / NAT | **强旁证(命中时)** |
|
||||||
|
| 日志回传资源名 | 按 tenancy 派生为 `<8hex>-audit*`,描述使用中性文案 | 弱 |
|
||||||
|
| GenAI Responses 请求头 | 同时写入 `CompartmentId` 与 `opc-compartment-id` | 中 / 弱 |
|
||||||
|
| API 调用序列 | 测活、账户画像、区域与资源查询存在稳定组合 | 辅助 |
|
||||||
|
|
||||||
|
SDK 默认 User-Agent、`opc-client-info` 与固定签名格式只说明“使用 OCI Go
|
||||||
|
SDK”,不是项目专属信号。
|
||||||
|
|
||||||
|
<a id="assessment"></a>
|
||||||
|
|
||||||
|
## 多租户关联评估
|
||||||
|
|
||||||
|
| 观察视角 | 结论 | 主要依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Oracle 内部,跨租户数据 | **中** | 固定文本、出口 IP 与调用序列可组合比对 |
|
||||||
|
| Oracle 内部,单租户数据 | **低** | 能识别实现痕迹,但难证明多个租户共用同一实例 |
|
||||||
|
| 仅看 SDK 请求头 | **低** | 可识别云身份和 SDK 类型,不能可靠识别具体应用 |
|
||||||
|
| 外部第三方 | **通常不可行** | 缺少 OCI 网关日志、Sign-on Policy 与租户资源元数据 |
|
||||||
|
|
||||||
|
这些等级表达的是“可关联性”,不是 Oracle 已执行关联或据此采取处置。
|
||||||
|
同一出口 IP 也可能来自 NAT、代理或共享基础设施,必须与其他信号联合判断。
|
||||||
|
|
||||||
|
<a id="evidence"></a>
|
||||||
|
|
||||||
|
## 核验依据
|
||||||
|
|
||||||
|
### OCI 与 SDK
|
||||||
|
|
||||||
|
- [OCI Request Signatures](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm)
|
||||||
|
- [OCI API Signing Key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm)
|
||||||
|
- [OCI Go SDK client.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go)
|
||||||
|
- [OCI Go SDK http_signer.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go)
|
||||||
|
- [OCI Go SDK configuration.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/configuration.go)
|
||||||
|
|
||||||
|
### 项目源码
|
||||||
|
|
||||||
|
- [API Key provider](../internal/oci/client.go#L237-L246)
|
||||||
|
- [手工签名请求](../internal/oci/account.go#L83-L97)
|
||||||
|
- [租户出站代理](../internal/oci/proxyhttp.go#L45-L65)
|
||||||
|
- [日志回传资源命名](../internal/oci/logrelay_names.go#L20-L55)
|
||||||
|
- [GenAI Responses 请求头](../internal/oci/genai_responses.go#L27-L38)
|
||||||
|
- [MFA justification](../internal/oci/signon.go#L223-L236)
|
||||||
|
- [审计与日志回传约束](../.trellis/spec/backend/oci-audit.md)
|
||||||
|
|
||||||
|
<p align="right"><a href="#top">返回顶部 ↑</a></p>
|
||||||
+2830
-23
File diff suppressed because it is too large
Load Diff
+2830
-23
File diff suppressed because it is too large
Load Diff
+1841
-23
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ go 1.26.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0
|
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/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.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/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
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/pquerna/otp v1.5.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
@@ -35,6 +37,7 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // 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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.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-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-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gofrs/flock v0.10.0 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/sony/gobreaker/v2 v2.4.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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/urfave/cli/v2 v2.3.0 // 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
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
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-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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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/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.122.0 h1:yB587yZUGe/syyyp1CHlW74EXo38Rfye5vD3Ox4Cdq4=
|
||||||
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
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/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 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
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 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
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=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
|||||||
+121
-7
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 分页查询内容日志
|
// @Summary 分页查询内容日志
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-content-logs [get]
|
// @Router /api/v1/ai-content-logs [get]
|
||||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
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})
|
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 ---- 聚合模型与调用日志 ----
|
// @Summary ---- 聚合模型与调用日志 ----
|
||||||
@@ -350,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary AI 调用日志列表
|
// @Summary AI 调用日志列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-logs [get]
|
// @Router /api/v1/ai-logs [get]
|
||||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||||
@@ -373,9 +423,51 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
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 网关全局设置(文档与响应共用)。
|
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||||
type aiSettingsResponse struct {
|
type aiSettingsResponse struct {
|
||||||
|
// FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||||
FilterDeprecated bool `json:"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 网关全局设置。
|
// aiSettings 返回 AI 网关全局设置。
|
||||||
@@ -386,15 +478,16 @@ type aiSettingsResponse struct {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [get]
|
// @Router /api/v1/ai-settings [get]
|
||||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
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 网关全局设置
|
// @Summary 更新 AI 网关全局设置
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒"
|
||||||
// @Success 200 {object} aiSettingsResponse
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [put]
|
// @Router /api/v1/ai-settings [put]
|
||||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
respondError(c, err)
|
||||||
return
|
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
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -552,7 +553,7 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI Responses 兼容端点
|
// @Summary OpenAI Responses 兼容端点
|
||||||
// @Tags AI 网关
|
// @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);直通仅建模常用字段,未列字段原样返回"
|
// @Success 200 {object} aiwire.RespResponse "OpenAI 兼容响应(非流式;流式为 SSE);直通仅建模常用字段,未列字段原样返回"
|
||||||
// @Router /ai/v1/responses [post]
|
// @Router /ai/v1/responses [post]
|
||||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||||
@@ -576,16 +577,27 @@ func (h *aiGatewayHandler) responses(c *gin.Context) {
|
|||||||
// OCI /actions/v1/responses,响应原样透传。
|
// OCI /actions/v1/responses,响应原样透传。
|
||||||
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
|
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
|
||||||
if err := service.RespPassthroughValidate(req); err != nil {
|
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())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := service.RespPassthroughBody(raw)
|
body, compat, err := service.RespPassthroughBody(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logRespCompat(req.Model, compat)
|
||||||
|
web, x := h.gw.GrokSearch()
|
||||||
|
if injectedBody, injected := service.RespInjectGrokTools(body, req.Model, web, x); len(injected) > 0 {
|
||||||
|
body = injectedBody
|
||||||
|
log.Printf("responses 直通(model=%s): 默认注入 %s", req.Model, strings.Join(injected, ", "))
|
||||||
|
}
|
||||||
if req.Stream {
|
if 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
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -597,6 +609,7 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
|||||||
h.logFailure(c, entry, req)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
payload = service.RespRestoreToolCalls(payload, compat)
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
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)
|
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 事件原样转发(推理增量等直达客户端),
|
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
|
||||||
// 逐行扫描 completed 事件提取 usage 记账。
|
// 逐行扫描 completed 事件提取 usage 记账;refs 非空时对 function_call 事件做
|
||||||
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
|
// namespace 还原后再转发。
|
||||||
|
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
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()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
usage, upErr, err := forwardSSE(c, upstream)
|
usage, upErr, err := forwardSSE(c, upstream, compat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
} else if upErr != "" {
|
} else if upErr != "" {
|
||||||
@@ -635,25 +714,29 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
||||||
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息。
|
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息;
|
||||||
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, error) {
|
// refs 非空时 data 行先做 namespace 还原(未改动的行原样转发)。
|
||||||
|
func forwardSSE(c *gin.Context, upstream io.Reader, compat service.RespCompat) (*aiwire.Usage, string, error) {
|
||||||
reader := bufio.NewReader(upstream)
|
reader := bufio.NewReader(upstream)
|
||||||
var usage *aiwire.Usage
|
var usage *aiwire.Usage
|
||||||
var upErr string
|
var upErr string
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadBytes('\n')
|
line, err := reader.ReadBytes('\n')
|
||||||
if len(line) > 0 {
|
if len(line) > 0 {
|
||||||
c.Writer.Write(line)
|
|
||||||
trimmed := bytes.TrimSpace(line)
|
trimmed := bytes.TrimSpace(line)
|
||||||
if len(trimmed) == 0 {
|
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||||
c.Writer.Flush()
|
c.Writer.Write(restoreSSELine(line, data, compat))
|
||||||
} else if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
|
||||||
if u := service.RespStreamCompletedUsage(data); u != nil {
|
if u := service.RespStreamCompletedUsage(data); u != nil {
|
||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
|
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
|
||||||
upErr = m
|
upErr = m
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
c.Writer.Write(line)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
|
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
|
||||||
@@ -29,7 +30,7 @@ func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
|
|||||||
|
|
||||||
func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||||
r, auth, _, db := newTestRouterDB(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 {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +126,7 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
|||||||
|
|
||||||
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
||||||
r, auth, _, db := newTestRouterDB(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 {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,7 @@ func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
|
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
|
||||||
r, auth, _, _ := newTestRouterDB(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 {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
|
|
||||||
// ---- 实例 IP ----
|
// ---- 实例 IP ----
|
||||||
|
|
||||||
// @Summary ---- 实例 IP ----
|
// @Summary 更换实例主网卡临时公网 IP
|
||||||
|
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @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})
|
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 地址
|
// @Summary 实例添加 IPv6 地址
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -34,7 +33,9 @@ type loginRequest struct {
|
|||||||
// @Param body body loginRequest true "登录凭据"
|
// @Param body body loginRequest true "登录凭据"
|
||||||
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
||||||
// @Failure 401 {object} errorResponse "凭据错误"
|
// @Failure 401 {object} errorResponse "凭据错误"
|
||||||
|
// @Failure 403 {object} errorResponse "密码登录已禁用"
|
||||||
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
||||||
|
// @Failure 429 {object} errorResponse "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited"
|
||||||
// @Router /api/v1/auth/login [post]
|
// @Router /api/v1/auth/login [post]
|
||||||
func (h *authHandler) login(c *gin.Context) {
|
func (h *authHandler) login(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
@@ -43,7 +44,7 @@ func (h *authHandler) login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
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) {
|
if errors.Is(err, service.ErrTotpRequired) {
|
||||||
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
||||||
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
||||||
@@ -80,9 +81,8 @@ func (h *authHandler) login(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/logout [post]
|
// @Router /api/v1/auth/logout [post]
|
||||||
func (h *authHandler) logout(c *gin.Context) {
|
func (h *authHandler) logout(c *gin.Context) {
|
||||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
if token := bearerToken(c); token != "" {
|
||||||
if ok && token != "" {
|
h.svc.Logout(c.Request.Context(), token)
|
||||||
h.svc.Logout(token)
|
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-31
@@ -18,6 +18,8 @@ import (
|
|||||||
type authxHandler struct {
|
type authxHandler struct {
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
oauth *service.OAuthService
|
oauth *service.OAuthService
|
||||||
|
passkeys *service.PasskeyService
|
||||||
|
wallets *service.WalletService
|
||||||
logs *service.SystemLogService
|
logs *service.SystemLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +67,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
|
|||||||
// @Summary 激活两步验证
|
// @Summary 激活两步验证
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{code: 6 位验证码}"
|
// @Param body body object true "{code: 6 位验证码}"
|
||||||
// @Success 204 "已启用"
|
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/activate [post]
|
// @Router /api/v1/auth/totp/activate [post]
|
||||||
func (h *authxHandler) totpActivate(c *gin.Context) {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 err != nil {
|
||||||
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -85,7 +90,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
}
|
}
|
||||||
|
|
||||||
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||||
@@ -93,7 +98,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
// @Summary 停用两步验证
|
// @Summary 停用两步验证
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{password 或 code 任一确认}"
|
// @Param body body object true "{password 或 code 任一确认}"
|
||||||
// @Success 204 "已停用"
|
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/disable [post]
|
// @Router /api/v1/auth/totp/disable [post]
|
||||||
func (h *authxHandler) totpDisable(c *gin.Context) {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 err != nil {
|
||||||
if errors.Is(err, service.ErrTotpConfirm) {
|
if errors.Is(err, service.ErrTotpConfirm) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -114,13 +122,14 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
|
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
|
||||||
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录。
|
// 旧令牌全部失效);旧令牌的会话行接续到新令牌,列表中当前设备保持连续。
|
||||||
func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
// 签发失败降级 204,前端按会话失效走重新登录。
|
||||||
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
|
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 {
|
if err != nil {
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -136,7 +145,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/revoke-sessions [post]
|
// @Router /api/v1/auth/revoke-sessions [post]
|
||||||
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -170,7 +179,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
|||||||
// @Summary 修改登录凭据
|
// @Summary 修改登录凭据
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||||
// @Success 204 "已更新,请重新登录"
|
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
|
||||||
|
// @Success 204 "已更新但新 token 签发失败,需重新登录"
|
||||||
// @Failure 401 {object} errorResponse "当前密码错误"
|
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [put]
|
// @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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -194,7 +204,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set(usernameKey, finalName)
|
c.Set(usernameKey, finalName)
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||||
@@ -202,7 +212,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
// @Summary 密码登录开关
|
// @Summary 密码登录开关
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{disabled: bool}"
|
// @Param body body object true "{disabled: bool}"
|
||||||
// @Success 204 "已保存"
|
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
|
||||||
|
// @Success 204 "已保存但新 token 签发失败,需重新登录"
|
||||||
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/password-login [put]
|
// @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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
if errors.Is(err, service.ErrNeedIdentity) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -223,26 +234,31 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth ----
|
// ---- OAuth ----
|
||||||
|
|
||||||
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
||||||
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
|
// 并附带密码登录禁用开关与通行密钥登录可用性:开关开启且存在可用外部身份时,
|
||||||
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
|
// 登录页隐藏密码表单;provider 清空时不下发禁用,保证界面始终留有登录入口
|
||||||
|
// (后端 Login 仍会拒绝)。
|
||||||
//
|
//
|
||||||
// @Summary 外部登录 provider 列表
|
// @Summary 外部登录 provider 列表
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} oauthProvidersResponse "providers 与 passwordLoginDisabled"
|
// @Success 200 {object} oauthProvidersResponse "providers、passwordLoginDisabled 与 passkeyLogin"
|
||||||
// @Router /api/v1/auth/oauth/providers [get]
|
// @Router /api/v1/auth/oauth/providers [get]
|
||||||
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||||
providers := h.oauth.Providers(c.Request.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
|
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
|
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 公开。
|
// 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 provider path string true "oidc / github"
|
||||||
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
||||||
// @Success 200 {object} urlResponse "url"
|
// @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]
|
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
||||||
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
@@ -268,7 +287,7 @@ func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
mode, username = "bind", name
|
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 err != nil {
|
||||||
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
|
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()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
@@ -306,7 +325,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
token, username, mode, err := h.oauth.HandleCallback(
|
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 {
|
if err != nil {
|
||||||
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||||
target := "/login"
|
target := "/login"
|
||||||
@@ -375,7 +394,8 @@ func (h *authxHandler) identities(c *gin.Context) {
|
|||||||
// @Summary 解绑外部身份
|
// @Summary 解绑外部身份
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param id path int true "身份 ID"
|
// @Param id path int true "身份 ID"
|
||||||
// @Success 204 "已解绑"
|
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
|
||||||
|
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
|
||||||
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities/{id} [delete]
|
// @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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
return
|
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) {
|
if errors.Is(err, service.ErrLastIdentity) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -393,7 +413,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth provider 设置(JWT 组内) ----
|
// ---- OAuth provider 设置(JWT 组内) ----
|
||||||
@@ -414,21 +434,31 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
|
||||||
|
// secret 传空串清除、缺省沿用。
|
||||||
//
|
//
|
||||||
// @Summary 保存 OAuth provider 配置
|
// @Summary 部分更新 OAuth provider 配置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.UpdateOAuthInput true "provider 配置"
|
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
|
||||||
// @Success 200 {object} service.OAuthProvidersView
|
// @Success 200 {object} service.OAuthProvidersView
|
||||||
|
// @Failure 400 {object} errorResponse "请求体非法"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
|
// @Failure 409 {object} errorResponse "密码登录禁用期间,该变更将移除最后可用登录方式"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/oauth [put]
|
// @Router /api/v1/settings/oauth [patch]
|
||||||
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||||
var req service.UpdateOAuthInput
|
var req service.UpdateOAuthInput
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,13 +1,121 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
|
||||||
|
type idpIconResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type idpSetupWarning struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ResourceCreated bool `json:"resourceCreated"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createIdpResponse struct {
|
||||||
|
oci.IdentityProviderInfo
|
||||||
|
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 上传身份提供商图标
|
||||||
|
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
|
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
|
||||||
|
// @Success 200 {object} idpIconResponse
|
||||||
|
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
|
||||||
|
// @Failure 413 {object} map[string]string "文件超过 1MB"
|
||||||
|
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
|
||||||
|
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
status, msg := iconFormStatus(err)
|
||||||
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
|
||||||
|
if err != nil {
|
||||||
|
status, msg := iconFormStatus(err)
|
||||||
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
|
||||||
|
if err != nil {
|
||||||
|
respondIdpIconErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除身份提供商图标
|
||||||
|
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
|
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
|
||||||
|
// @Success 204 "无内容"
|
||||||
|
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
|
||||||
|
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
|
||||||
|
respondIdpIconErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
|
||||||
|
func iconFormStatus(err error) (int, string) {
|
||||||
|
var mbe *http.MaxBytesError
|
||||||
|
if errors.As(err, &mbe) {
|
||||||
|
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
|
||||||
|
}
|
||||||
|
return http.StatusBadRequest, "缺少文件字段 file"
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||||
|
func respondIdpIconErr(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrIdpIconTooLarge):
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconBadType):
|
||||||
|
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconEmpty):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconBadName):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
|
||||||
|
default:
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||||
|
|
||||||
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||||
@@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 创建身份提供商(SAML)
|
// @Summary 创建身份提供商(SAML)
|
||||||
|
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createIdpRequest true "请求体"
|
// @Param body body createIdpRequest true "请求体"
|
||||||
// @Success 201 {object} oci.IdentityProviderInfo
|
// @Success 201 {object} createIdpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||||
@@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||||
JitMapEmail: req.JitMapEmail,
|
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)
|
respondError(c, err)
|
||||||
return
|
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 激活身份提供商
|
// @Summary 激活身份提供商
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ type createInstanceRequest struct {
|
|||||||
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
|
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
|
||||||
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
|
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
|
||||||
AssignPublicIP bool `json:"assignPublicIp"`
|
AssignPublicIP bool `json:"assignPublicIp"`
|
||||||
|
ReservedPublicIPID string `json:"reservedPublicIpId"` // 非空时不分配临时 IP,实例就绪后自动绑定该保留 IP
|
||||||
AssignIpv6 bool `json:"assignIpv6"`
|
AssignIpv6 bool `json:"assignIpv6"`
|
||||||
SSHPublicKey string `json:"sshPublicKey"`
|
SSHPublicKey string `json:"sshPublicKey"`
|
||||||
RootPassword string `json:"rootPassword"`
|
RootPassword string `json:"rootPassword"`
|
||||||
@@ -118,6 +120,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
|||||||
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
|
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
|
||||||
SubnetID: req.SubnetID,
|
SubnetID: req.SubnetID,
|
||||||
AssignPublicIP: req.AssignPublicIP,
|
AssignPublicIP: req.AssignPublicIP,
|
||||||
|
ReservedPublicIPID: req.ReservedPublicIPID,
|
||||||
AssignIpv6: req.AssignIpv6,
|
AssignIpv6: req.AssignIpv6,
|
||||||
SSHPublicKey: req.SSHPublicKey,
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
RootPassword: req.RootPassword,
|
RootPassword: req.RootPassword,
|
||||||
@@ -200,6 +203,7 @@ func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
|
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
|
||||||
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
||||||
@@ -207,20 +211,43 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
instanceID := c.Param("instanceId")
|
||||||
|
if !beginInstanceLifecycleOperation(instanceID) {
|
||||||
|
respondInstanceLifecycleBusy(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer endInstanceLifecycleOperation(instanceID)
|
||||||
preserve := c.Query("preserveBootVolume") == "true"
|
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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
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 实例电源操作(启停/重启)
|
// @Summary 实例电源操作(启停/重启)
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body instanceActionRequest true "请求体"
|
// @Param body body instanceActionRequest true "请求体"
|
||||||
// @Success 200 {object} oci.Instance
|
// @Success 200 {object} oci.Instance
|
||||||
|
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
||||||
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -12,11 +13,17 @@ import (
|
|||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键;
|
||||||
const usernameKey = "username"
|
// tokenVerKey/tokenJtiKey 是鉴权时观察到的令牌快照,敏感事务提交前复核。
|
||||||
|
const (
|
||||||
|
usernameKey = "username"
|
||||||
|
tokenVerKey = "tokenVer"
|
||||||
|
tokenJtiKey = "tokenJti"
|
||||||
|
)
|
||||||
|
|
||||||
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
||||||
// 通过后把用户名放进上下文。
|
// 通过后把用户名与令牌快照(版本/jti)放进上下文——快照供敏感事务在
|
||||||
|
// 行锁下复核,防「请求挂起期间令牌被撤销,恢复后仍完成变更」的在途绕过。
|
||||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
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"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username, err := auth.ParseToken(c.Request.Context(), token)
|
username, proof, err := auth.ParseTokenProof(c.Request.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set(usernameKey, username)
|
c.Set(usernameKey, username)
|
||||||
|
c.Set(tokenVerKey, proof.Ver)
|
||||||
|
c.Set(tokenJtiKey, proof.Jti)
|
||||||
c.Next()
|
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 截断报错,
|
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
|
||||||
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
|
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
|
||||||
func bodyLimit(n int64) gin.HandlerFunc {
|
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 {
|
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -101,17 +117,23 @@ func errMsgOf(status int, head []byte) string {
|
|||||||
return truncateLogField(body.Error, 256)
|
return truncateLogField(body.Error, 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。
|
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽;
|
||||||
|
// 截断点回退到 rune 边界,避免产生非法 UTF-8(严格 MySQL/PG 会拒写整行)。
|
||||||
func truncateLogField(s string, max int) string {
|
func truncateLogField(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
for max > 0 && !utf8.RuneStart(s[max]) {
|
||||||
|
max--
|
||||||
|
}
|
||||||
return s[:max]
|
return s[:max]
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWriteMethod 判断是否需要留痕的写方法。
|
// isWriteMethod 判断是否需要留痕的写方法;PATCH 承载 OAuth / 安全设置等
|
||||||
|
// 敏感配置变更,必须与 POST/PUT/DELETE 一样进入审计。
|
||||||
func isWriteMethod(m string) bool {
|
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 记入日志),
|
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -277,6 +277,12 @@ func respondError(c *gin.Context, err error) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 请求处理期间令牌已失效(撤销/注销/并发敏感操作后到):按未认证处理,
|
||||||
|
// 前端「发送时令牌==当前令牌」快照决定是否登出
|
||||||
|
if errors.Is(err, service.ErrTokenStale) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
id := newRequestID()
|
id := newRequestID()
|
||||||
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
|
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -77,7 +77,9 @@ func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
sec := settings.SecurityCached()
|
sec := settings.SecurityCached()
|
||||||
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
|
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
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -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
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
|
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
|
||||||
// 具体路由按域拆在 routes_*.go。/auth/login 公开,业务路由要求 JWT。
|
// 具体路由按域拆在 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()
|
r := gin.New()
|
||||||
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
@@ -23,17 +23,24 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
|||||||
|
|
||||||
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
|
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
|
||||||
v1 := r.Group("/api/v1", bodyLimit(1<<20))
|
v1 := r.Group("/api/v1", bodyLimit(1<<20))
|
||||||
registerAuthPublic(v1, auth, oauth, systemLogs)
|
registerAuthPublic(v1, auth, oauth, passkeys, wallets, systemLogs)
|
||||||
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||||
registerAiGateway(r, aiGateway)
|
registerAiGateway(r, aiGateway)
|
||||||
|
|
||||||
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
||||||
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
|
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)
|
registerConsole(v1, secured, console, auth)
|
||||||
registerSettings(secured, settings, notifier, systemLogs, proxies)
|
registerSettings(secured, auth, settings, notifier, systemLogs, proxies)
|
||||||
registerTasksAndLogs(secured, tasks, logEvents)
|
registerTasksAndLogs(secured, tasks, logEvents)
|
||||||
registerOci(secured, ociConfigs)
|
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)
|
registerAiAdmin(secured, aiGateway)
|
||||||
registerSwagger(r)
|
registerSwagger(r)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
|
|||||||
return oci.SubscriptionDetail{}, nil
|
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) {
|
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r, auth, systemLogs, _ := newTestRouterDB(t)
|
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)
|
t.Fatalf("db handle: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
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)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
cipher, err := crypto.NewCipher("test-key")
|
cipher, err := crypto.NewCipher("test-key")
|
||||||
@@ -96,7 +104,9 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
|
|||||||
systemLogs := service.NewSystemLogService(db)
|
systemLogs := service.NewSystemLogService(db)
|
||||||
logEvents := service.NewLogEventService(db)
|
logEvents := service.NewLogEventService(db)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
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
|
return r, auth, systemLogs, db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +148,7 @@ func TestLoginEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
func TestSecuredRoutesRequireToken(t *testing.T) {
|
func TestSecuredRoutesRequireToken(t *testing.T) {
|
||||||
r, auth, _ := newTestRouter(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 {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +173,7 @@ func TestSecuredRoutesRequireToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestSystemLogsEndpoint(t *testing.T) {
|
func TestSystemLogsEndpoint(t *testing.T) {
|
||||||
r, auth, logs := newTestRouter(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 {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
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-models", aiadmin.gatewayModels)
|
||||||
|
secured.GET("/ai-model-catalog", aiadmin.modelCatalog)
|
||||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||||
|
|||||||
@@ -6,23 +6,44 @@ import (
|
|||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerAuthPublic 公开登录/OAuth 路由(不经 JWT)。
|
// registerAuthPublic 公开登录/OAuth/通行密钥/钱包路由(不经 JWT)。
|
||||||
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, systemLogs *service.SystemLogService) {
|
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}
|
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||||
v1.POST("/auth/login", ah.login)
|
v1.POST("/auth/login", ah.login)
|
||||||
|
|
||||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
// 外部身份登录: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/providers", ax.oauthProviders)
|
||||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
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 组内)。
|
// registerAuthSecured 登出、两步验证、外部身份与通行密钥管理(JWT 组内)。
|
||||||
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, settings *service.SettingService, systemLogs *service.SystemLogService) {
|
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}
|
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||||
secured.POST("/auth/logout", ah.logout)
|
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}
|
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||||
secured.GET("/auth/totp", ax.totpStatus)
|
secured.GET("/auth/totp", ax.totpStatus)
|
||||||
secured.POST("/auth/totp/setup", ax.totpSetup)
|
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.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||||
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
||||||
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func registerOci(secured *gin.RouterGroup, ociConfigs *service.OciConfigService)
|
|||||||
registerOciCompute(secured, h)
|
registerOciCompute(secured, h)
|
||||||
registerOciNetwork(secured, h)
|
registerOciNetwork(secured, h)
|
||||||
registerOciStorage(secured, h)
|
registerOciStorage(secured, h)
|
||||||
|
registerOciObjectStorage(secured, h)
|
||||||
registerOciCost(secured, h)
|
registerOciCost(secured, h)
|
||||||
registerOciTenantIAM(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/limits/services", h.limitServices)
|
||||||
g.GET("/oci-configs/:id/subscriptions", h.subscriptions)
|
g.GET("/oci-configs/:id/subscriptions", h.subscriptions)
|
||||||
g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
|
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/shapes", h.shapes)
|
||||||
g.GET("/oci-configs/:id/images", h.images)
|
g.GET("/oci-configs/:id/images", h.images)
|
||||||
g.GET("/oci-configs/:id/images/:imageId", h.getImage)
|
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.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
|
||||||
g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
|
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/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)
|
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.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
|
||||||
g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
|
g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
|
||||||
g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
|
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.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
|
||||||
g.POST("/oci-configs/:id/security-lists", h.createSecurityList)
|
g.POST("/oci-configs/:id/security-lists", h.createSecurityList)
|
||||||
g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
|
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)
|
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 成本快照。
|
// registerOciCost 成本快照。
|
||||||
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||||
g.GET("/oci-configs/:id/costs", h.costs)
|
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.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/mfa-devices", h.deleteTenantUserMfa)
|
||||||
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
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.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||||
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||||
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
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.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||||
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// registerSettings 挂载 /settings/* 与 /about,以及系统日志与代理管理。
|
// 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("/about", about)
|
||||||
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
|
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.GET("/settings/telegram", st.getTelegram)
|
||||||
secured.PUT("/settings/telegram", st.updateTelegram)
|
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||||
secured.POST("/settings/telegram/test", st.testTelegram)
|
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.GET("/settings/task", st.getTaskSettings)
|
||||||
secured.PUT("/settings/task", st.updateTaskSettings)
|
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||||
secured.GET("/settings/security", st.getSecurity)
|
secured.GET("/settings/security", st.getSecurity)
|
||||||
secured.PUT("/settings/security", st.updateSecurity)
|
secured.PATCH("/settings/security", st.updateSecurity)
|
||||||
|
|
||||||
px := &proxyHandler{svc: proxies}
|
px := &proxyHandler{svc: proxies}
|
||||||
secured.GET("/proxies", px.list)
|
secured.GET("/proxies", px.list)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
||||||
type settingsHandler struct {
|
type settingsHandler struct {
|
||||||
svc *service.SettingService
|
svc *service.SettingService
|
||||||
|
auth *service.AuthService
|
||||||
notifier *service.Notifier
|
notifier *service.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,25 +317,35 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
|
||||||
|
// 越界或非法返回 400,保存后立即生效。
|
||||||
//
|
//
|
||||||
// @Summary 保存安全设置并返回最新值
|
// @Summary 部分更新安全设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.SecuritySettings true "请求体"
|
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
|
||||||
// @Success 200 {object} service.SecuritySettings
|
// @Success 200 {object} service.SecuritySettings
|
||||||
|
// @Failure 400 {object} errorResponse "字段越界或非法"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
|
// @Failure 409 {object} errorResponse "密码登录禁用期间,面板地址变更将移除最后可用登录方式"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [put]
|
// @Router /api/v1/settings/security [patch]
|
||||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||||
var req service.SecuritySettings
|
var req service.SecurityPatch
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
if errors.Is(err, service.ErrInvalidSecurity) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, service.ErrProviderLastLogin) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
// errorResponse 是统一错误响应外壳。
|
// errorResponse 是统一错误响应外壳。
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
// Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429
|
||||||
|
// 返回 "RateLimited",前端据此与登录守卫的 429(无 code)区分
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemsResponse 是 {"items": [...]} 列表外壳。
|
// itemsResponse 是 {"items": [...]} 列表外壳。
|
||||||
@@ -71,6 +74,20 @@ type credentialsResponse struct {
|
|||||||
type oauthProvidersResponse struct {
|
type oauthProvidersResponse struct {
|
||||||
Providers []service.ProviderInfo `json:"providers"`
|
Providers []service.ProviderInfo `json:"providers"`
|
||||||
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
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 是跳转地址响应。
|
// urlResponse 是跳转地址响应。
|
||||||
@@ -157,6 +174,11 @@ type deletedApiKeysResponse struct {
|
|||||||
DeletedApiKeys int `json:"deletedApiKeys"`
|
DeletedApiKeys int `json:"deletedApiKeys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userApiKeysResponse 是用户 API 签名 key 列表。
|
||||||
|
type userApiKeysResponse struct {
|
||||||
|
Items []service.UserApiKeyInfo `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
||||||
type createInstancesResponse struct {
|
type createInstancesResponse struct {
|
||||||
Instances []oci.Instance `json:"instances"`
|
Instances []oci.Instance `json:"instances"`
|
||||||
|
|||||||
+16
-2
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
|
|||||||
// @Summary 更新任务
|
// @Summary 更新任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Param body body updateTaskRequest true "可更新字段"
|
// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
|
||||||
// @Success 200 {object} model.Task
|
// @Success 200 {object} model.Task
|
||||||
|
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
|
||||||
|
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [put]
|
// @Router /api/v1/tasks/{id} [put]
|
||||||
func (h *taskHandler) update(c *gin.Context) {
|
func (h *taskHandler) update(c *gin.Context) {
|
||||||
@@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
|
|||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondUpdateTaskErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, task)
|
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 删除任务
|
// @Summary 删除任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
|
|||||||
+105
-2
@@ -38,6 +38,11 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
|||||||
// @Summary 配置成本快照
|
// @Summary 配置成本快照
|
||||||
// @Tags 成本
|
// @Tags 成本
|
||||||
// @Param id path int true "配置 ID"
|
// @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
|
// @Success 200 {array} oci.CostItem
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/costs [get]
|
// @Router /api/v1/oci-configs/{id}/costs [get]
|
||||||
@@ -68,13 +73,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
|||||||
// ---- 租户审计日志 ----
|
// ---- 租户审计日志 ----
|
||||||
|
|
||||||
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。
|
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
|
||||||
|
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
|
||||||
//
|
//
|
||||||
// @Summary 批式懒加载查询租户 OCI 审计事件
|
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||||
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||||
|
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||||
// @Success 200 {object} service.AuditEventsView
|
// @Success 200 {object} service.AuditEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||||
@@ -84,7 +91,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
|
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit, Q: c.Query("q")}
|
||||||
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||||
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -364,6 +371,102 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
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 ---- 域通知收件人与密码策略 ----
|
// @Summary ---- 域通知收件人与密码策略 ----
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ func buildDialector(driver, dsn, path string) (gorm.Dialector, error) {
|
|||||||
|
|
||||||
func autoMigrate(db *gorm.DB) error {
|
func autoMigrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(
|
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.CheckSnapshot{}, &model.CostSnapshot{},
|
||||||
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||||
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||||
|
|||||||
@@ -86,6 +86,45 @@ type UserIdentity struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
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 (
|
const (
|
||||||
TaskTypeHealthCheck = "health_check" // 定时测活
|
TaskTypeHealthCheck = "health_check" // 定时测活
|
||||||
@@ -229,6 +268,7 @@ type CompartmentCache struct {
|
|||||||
OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"`
|
OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"`
|
||||||
OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"`
|
OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"`
|
||||||
Name string `gorm:"size:128" json:"name"`
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
ParentOCID string `gorm:"size:128" json:"parentId"`
|
||||||
State string `gorm:"size:16" json:"lifecycleState"`
|
State string `gorm:"size:16" json:"lifecycleState"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -268,8 +308,12 @@ type AiChannel struct {
|
|||||||
LastProbeAt *time.Time `json:"lastProbeAt"`
|
LastProbeAt *time.Time `json:"lastProbeAt"`
|
||||||
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
||||||
ProbeError string `gorm:"size:512" json:"probeError"`
|
ProbeError string `gorm:"size:512" json:"probeError"`
|
||||||
|
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
|
||||||
|
ProbeModel string `gorm:"size:96" json:"probeModel"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
// ModelCount 是渠道模型缓存计数,列表查询回填,不落库
|
||||||
|
ModelCount int64 `gorm:"-" json:"modelCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||||
|
|||||||
+471
-186
@@ -6,19 +6,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
// maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条,
|
||||||
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
// 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查。
|
||||||
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
|
||||||
const maxAuditPages = 10
|
const maxAuditPages = 10
|
||||||
|
|
||||||
|
// auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时
|
||||||
|
// 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求,
|
||||||
|
// 前端按已回溯位置展示进度并自动续查。
|
||||||
|
const auditBatchTimeBudget = 20 * time.Second
|
||||||
|
|
||||||
|
// auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询
|
||||||
|
// 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。
|
||||||
|
const auditSearchPageLimit = 200
|
||||||
|
|
||||||
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||||
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存,
|
||||||
// 列表响应不再携带(详情接口按 eventId 取回)。
|
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||||
type AuditEvent struct {
|
type AuditEvent struct {
|
||||||
EventId string `json:"eventId"`
|
EventId string `json:"eventId"`
|
||||||
@@ -35,7 +45,7 @@ type AuditEvent struct {
|
|||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断,
|
||||||
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||||
type AuditEventsResult struct {
|
type AuditEventsResult struct {
|
||||||
Items []AuditEvent `json:"items"`
|
Items []AuditEvent `json:"items"`
|
||||||
@@ -43,6 +53,324 @@ type AuditEventsResult struct {
|
|||||||
NextPage string `json:"nextPage,omitempty"`
|
NextPage string `json:"nextPage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditSearchClient 构造区域化的日志搜索客户端。审计数据源为 Logging Search
|
||||||
|
// 的 _Audit 日志:Audit API 无排序参数、窗口内固定按处理时间正序,首批只能
|
||||||
|
// 拿到窗口内最旧的一段;Logging Search 支持 datetime 倒序,才能从最新回溯。
|
||||||
|
func (c *RealClient) auditSearchClient(cred Credentials, region string) (loggingsearch.LogSearchClient, error) {
|
||||||
|
sc, err := loggingsearch.NewLogSearchClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return sc, fmt.Errorf("new logging search client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
sc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSearchQuery 组装租户根 compartment 审计日志的倒序检索语句;
|
||||||
|
// SummarizeMetricsData 遥测噪声占比高,服务端先滤一道减少无效翻页。
|
||||||
|
// q 非空时追加 logContent 全文包含匹配——它扫的是整条 JSON 的所有值,只当
|
||||||
|
// 粗筛;可见字段的精筛由 filterAuditTerm 兜底,避免隐藏元数据误命中。
|
||||||
|
func auditSearchQuery(tenancyOCID, q string) string {
|
||||||
|
query := fmt.Sprintf("search %q | where data.eventName != 'SummarizeMetricsData'", tenancyOCID+"/_Audit")
|
||||||
|
if term := SanitizeAuditTerm(q); term != "" {
|
||||||
|
query += fmt.Sprintf(" and logContent = '*%s*'", term)
|
||||||
|
}
|
||||||
|
return query + " | sort by datetime desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditTermMaxLen 限制检索关键字长度,防止游标与查询语句被撑爆。
|
||||||
|
const auditTermMaxLen = 100
|
||||||
|
|
||||||
|
// SanitizeAuditTerm 归一检索关键字:去除引号/反斜杠/控制字符防语句注入
|
||||||
|
// (查询目标已锁定本租户 _Audit 流,注入最坏只是语法错),截断超长输入;
|
||||||
|
// 保留 * 供用户通配。返回空串表示不追加过滤子句。
|
||||||
|
// service 构造首查游标与本包组装语句共用,对篡改游标二次消毒兜底。
|
||||||
|
func SanitizeAuditTerm(q string) string {
|
||||||
|
out := make([]rune, 0, len(q))
|
||||||
|
for _, r := range q {
|
||||||
|
if r == '\'' || r == '"' || r == '\\' || r < 0x20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
if len(out) >= auditTermMaxLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||||
|
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||||
|
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||||
|
// Search 配额为零的租户自动回退 Audit API 重查同一窗口(page 跨通道失效,重头吃窗)。
|
||||||
|
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||||
|
f := c.newAuditFetchers(cred, region)
|
||||||
|
res, err := listAuditWindow(ctx, f.search, AuditCursor{Start: start, End: end, Page: page})
|
||||||
|
if err != nil && isSearchQuotaZero(err) {
|
||||||
|
res, err = listAuditWindow(ctx, f.audit, AuditCursor{Start: start, End: end})
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuditWindow 用给定取页函数吃一个固定时间窗,最多 maxAuditPages 页;
|
||||||
|
// 页预算耗尽即截断,NextPage 携带未消费的窗内游标。
|
||||||
|
func listAuditWindow(ctx context.Context, fetch auditPageFetch, cur AuditCursor) (AuditEventsResult, error) {
|
||||||
|
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||||
|
for i := 0; i < maxAuditPages; i++ {
|
||||||
|
items, next, err := fetch(ctx, cur)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEventsResult{}, err
|
||||||
|
}
|
||||||
|
result.Items = appendKeptAuditEvents(result.Items, items)
|
||||||
|
if next == "" {
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
cur.Page = next
|
||||||
|
}
|
||||||
|
result.Truncated = true
|
||||||
|
result.NextPage = cur.Page
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditPageFetch 拉取游标位置的一页已映射事件,返回窗内下一页游标。
|
||||||
|
type auditPageFetch func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error)
|
||||||
|
|
||||||
|
// auditFetchers 汇集两条数据通道:search 为 Logging Search 倒序主路,
|
||||||
|
// audit 为 Search 配额为零租户的 Audit API 回退路。
|
||||||
|
type auditFetchers struct {
|
||||||
|
search auditPageFetch
|
||||||
|
audit auditPageFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuditFetchers 构造两条通道的取页闭包;客户端惰性初始化,
|
||||||
|
// 各模式的续查不会白建用不到的客户端。
|
||||||
|
func (c *RealClient) newAuditFetchers(cred Credentials, region string) auditFetchers {
|
||||||
|
return auditFetchers{search: c.searchFetcher(cred, region), audit: c.auditAPIFetcher(cred, region)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchFetcher 构造 Logging Search 通道的取页闭包。
|
||||||
|
func (c *RealClient) searchFetcher(cred Credentials, region string) auditPageFetch {
|
||||||
|
var sc *loggingsearch.LogSearchClient
|
||||||
|
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
if sc == nil {
|
||||||
|
cli, err := c.auditSearchClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
sc = &cli
|
||||||
|
}
|
||||||
|
return searchAuditPage(ctx, *sc, cred.TenancyOCID, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditAPIFetcher 构造 Audit API 回退通道的取页闭包。
|
||||||
|
func (c *RealClient) auditAPIFetcher(cred Credentials, region string) auditPageFetch {
|
||||||
|
var ac *audit.AuditClient
|
||||||
|
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
if ac == nil {
|
||||||
|
cli, err := c.auditClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ac = &cli
|
||||||
|
}
|
||||||
|
return listAuditPage(ctx, *ac, cred.TenancyOCID, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSearchQuotaZero 识别「租户 Logging Search 配额为零」的失败:此类租户该
|
||||||
|
// 服务永久不可用(maxQueriesPerMinute/maxConcurrentQueries 均为 0),应回退
|
||||||
|
// Audit API;普通限流(配额非零)不回退,避免数据通道来回切换。
|
||||||
|
func isSearchQuotaZero(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.ReplaceAll(err.Error(), " ", ""))
|
||||||
|
return strings.Contains(msg, "ratelimitexceeded") && strings.Contains(msg, "maxqueriesperminute:0,")
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendKeptAuditEvents 过滤噪声后追加一页已映射事件;窗口式与批式查询共用。
|
||||||
|
func appendKeptAuditEvents(dst []AuditEvent, items []AuditEvent) []AuditEvent {
|
||||||
|
for _, ev := range items {
|
||||||
|
if keepAuditEvent(ev) {
|
||||||
|
dst = append(dst, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
||||||
|
|
||||||
|
// 批式查询参数:单批翻页预算沿用 maxAuditPages;首窗 24h,连续空窗倍增
|
||||||
|
// 加速跨越闲置期,上限 14 天(Logging Search 单次查询时间窗硬限);
|
||||||
|
// 回溯下限为审计事件保留期 365 天。
|
||||||
|
const (
|
||||||
|
auditWindowHours = 24
|
||||||
|
auditWindowMaxHours = 336
|
||||||
|
auditRetentionDays = 365
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditModeFallback 标记游标处于 Audit API 回退模式:部分租户的
|
||||||
|
// Logging Search 服务配额为零(maxQueriesPerMinute: 0),永久不可用。
|
||||||
|
const auditModeFallback = "a"
|
||||||
|
|
||||||
|
// auditFallbackWindowHours 是回退模式的基准窗宽:Audit API 窗口内固定按
|
||||||
|
// 处理时间正序且无排序参数,只能小窗回溯 + 前端全局重排保住从新到旧的体验。
|
||||||
|
const auditFallbackWindowHours = 1
|
||||||
|
|
||||||
|
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内翻页游标、当前窗宽
|
||||||
|
// (小时,空窗倍增的记忆)、检索关键字(随游标续查,保证跨批过滤一致)
|
||||||
|
// 与数据通道模式(空为 Search 主路,"a" 为 Audit API 回退,续查沿用不再试错)。
|
||||||
|
// 序列化为不透明 cursor 由 service 层负责。
|
||||||
|
type AuditCursor struct {
|
||||||
|
Start time.Time `json:"s"`
|
||||||
|
End time.Time `json:"e"`
|
||||||
|
Page string `json:"p,omitempty"`
|
||||||
|
WindowHours int `json:"w"`
|
||||||
|
Q string `json:"q,omitempty"`
|
||||||
|
M string `json:"m,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFallback 把游标切到 Audit API 回退模式:Search 页游标跨通道失效须清空;
|
||||||
|
// 首窗收窄到基准窗宽,避免大窗正序分页又回到「首批全是窗口内最旧事件」的老问题。
|
||||||
|
func (cur AuditCursor) toFallback() AuditCursor {
|
||||||
|
cur.M = auditModeFallback
|
||||||
|
cur.Page = ""
|
||||||
|
cur.WindowHours = auditFallbackWindowHours
|
||||||
|
if cur.End.Sub(cur.Start) > auditFallbackWindowHours*time.Hour {
|
||||||
|
cur.Start = cur.End.Add(-auditFallbackWindowHours * time.Hour)
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
||||||
|
func NewAuditCursor(now time.Time) AuditCursor {
|
||||||
|
end := now.UTC().Truncate(time.Minute)
|
||||||
|
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
||||||
|
// 否则重置为该模式基准窗宽。done 为 true 表示已越过保留期尽头。
|
||||||
|
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
||||||
|
base := auditWindowHours
|
||||||
|
if cur.M == auditModeFallback {
|
||||||
|
base = auditFallbackWindowHours
|
||||||
|
}
|
||||||
|
w := cur.WindowHours
|
||||||
|
if w <= 0 {
|
||||||
|
w = base
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
if w *= 2; w > auditWindowMaxHours {
|
||||||
|
w = auditWindowMaxHours
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w = base
|
||||||
|
}
|
||||||
|
end := cur.Start
|
||||||
|
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
||||||
|
return cur, true
|
||||||
|
}
|
||||||
|
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w, Q: cur.Q, M: cur.M}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
||||||
|
// 表示已回溯到保留期尽头,无更早数据。
|
||||||
|
type AuditBatchResult struct {
|
||||||
|
Items []AuditEvent
|
||||||
|
Cursor *AuditCursor
|
||||||
|
Exhausted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
||||||
|
// 保留事件;单批受页预算与时间预算双重约束,不足额也返回,由前端按需续查。
|
||||||
|
// 倒序返回下,窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
||||||
|
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||||
|
return listAuditBatch(ctx, c.newAuditFetchers(cred, region), cur, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuditBatch 是批式回溯的通道无关内核,取页函数注入便于测试。
|
||||||
|
func listAuditBatch(ctx context.Context, f auditFetchers, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||||
|
res := AuditBatchResult{Items: []AuditEvent{}}
|
||||||
|
windowHasKept := false
|
||||||
|
deadline := time.Now().Add(auditBatchTimeBudget)
|
||||||
|
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit && time.Now().Before(deadline); budget-- {
|
||||||
|
items, next, nextCur, err := fetchAuditPage(ctx, f, cur)
|
||||||
|
if err != nil {
|
||||||
|
return AuditBatchResult{}, err
|
||||||
|
}
|
||||||
|
cur = nextCur
|
||||||
|
before := len(res.Items)
|
||||||
|
res.Items = appendKeptAuditEvents(res.Items, filterAuditTerm(items, cur))
|
||||||
|
windowHasKept = windowHasKept || len(res.Items) > before
|
||||||
|
if next != "" {
|
||||||
|
cur.Page = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adv, done := cur.advance(time.Now(), !windowHasKept)
|
||||||
|
if done {
|
||||||
|
res.Exhausted = true
|
||||||
|
sortAuditEvents(res.Items)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
cur, windowHasKept = adv, false
|
||||||
|
}
|
||||||
|
sortAuditEvents(res.Items)
|
||||||
|
res.Cursor = &cur
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAuditPage 按游标模式取一页;Search 主路报「配额为零」时切到回退游标
|
||||||
|
// 并立即用 Audit API 重试,后续批次凭游标模式直达回退通道不再试错。
|
||||||
|
func fetchAuditPage(ctx context.Context, f auditFetchers, cur AuditCursor) ([]AuditEvent, string, AuditCursor, error) {
|
||||||
|
if cur.M == auditModeFallback {
|
||||||
|
items, next, err := f.audit(ctx, cur)
|
||||||
|
return items, next, cur, err
|
||||||
|
}
|
||||||
|
items, next, err := f.search(ctx, cur)
|
||||||
|
if err != nil && isSearchQuotaZero(err) {
|
||||||
|
cur = cur.toFallback()
|
||||||
|
items, next, err = f.audit(ctx, cur)
|
||||||
|
}
|
||||||
|
return items, next, cur, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterAuditTerm 关键字精筛:只认列表可见字段(matchesAuditTerm),两条通道
|
||||||
|
// 语义一致。Search 主路的 logContent 全文条件会命中隐藏认证元数据(如
|
||||||
|
// opc-principal 头里的 ttype:login),只作粗筛减少翻页,不作为最终判定。
|
||||||
|
func filterAuditTerm(items []AuditEvent, cur AuditCursor) []AuditEvent {
|
||||||
|
if cur.Q == "" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
out := items[:0]
|
||||||
|
for _, ev := range items {
|
||||||
|
if matchesAuditTerm(ev, cur.Q) {
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAuditTerm 判断事件是否命中关键字:不区分大小写的包含匹配,
|
||||||
|
// * 作为通配分段、各段都出现即命中,近似 Search 通道的 logContent 语义。
|
||||||
|
func matchesAuditTerm(ev AuditEvent, q string) bool {
|
||||||
|
hay := strings.ToLower(strings.Join([]string{
|
||||||
|
ev.EventName, ev.Source, ev.ResourceName, ev.CompartmentName,
|
||||||
|
ev.PrincipalName, ev.IPAddress, ev.Status, ev.RequestAction, ev.RequestPath,
|
||||||
|
}, "\n"))
|
||||||
|
for _, part := range strings.Split(strings.ToLower(q), "*") {
|
||||||
|
if part != "" && !strings.Contains(hay, part) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditClient 构造区域化的 Audit API 客户端(回退通道)。
|
||||||
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||||
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl
|
|||||||
return ac, nil
|
return ac, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
// listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定
|
||||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
// 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文,
|
||||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
// 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染。
|
||||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
ac, err := c.auditClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return AuditEventsResult{}, err
|
|
||||||
}
|
|
||||||
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
|
|
||||||
req := audit.ListEventsRequest{
|
|
||||||
CompartmentId: &cred.TenancyOCID,
|
|
||||||
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
|
||||||
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
|
||||||
}
|
|
||||||
if page != "" {
|
|
||||||
req.Page = &page
|
|
||||||
}
|
|
||||||
result := AuditEventsResult{Items: []AuditEvent{}}
|
|
||||||
for i := 0; i < maxAuditPages; i++ {
|
|
||||||
resp, err := ac.ListEvents(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
|
|
||||||
}
|
|
||||||
appendAuditEvents(&result, resp.Items)
|
|
||||||
if resp.OpcNextPage == nil {
|
|
||||||
sortAuditEvents(result.Items)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
req.Page = resp.OpcNextPage
|
|
||||||
}
|
|
||||||
result.Truncated = true
|
|
||||||
result.NextPage = deref(req.Page)
|
|
||||||
sortAuditEvents(result.Items)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
|
||||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
|
||||||
result.Items = appendKeptAuditEvents(result.Items, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
|
|
||||||
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
|
|
||||||
for _, ev := range items {
|
|
||||||
out := toAuditEvent(ev)
|
|
||||||
if !keepAuditEvent(out) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
|
||||||
out.Raw = raw
|
|
||||||
}
|
|
||||||
dst = append(dst, out)
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
|
||||||
|
|
||||||
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
|
|
||||||
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
|
|
||||||
const (
|
|
||||||
auditWindowHours = 24
|
|
||||||
auditWindowMaxHours = 720
|
|
||||||
auditRetentionDays = 365
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
|
|
||||||
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
|
|
||||||
type AuditCursor struct {
|
|
||||||
Start time.Time `json:"s"`
|
|
||||||
End time.Time `json:"e"`
|
|
||||||
Page string `json:"p,omitempty"`
|
|
||||||
WindowHours int `json:"w"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
|
||||||
func NewAuditCursor(now time.Time) AuditCursor {
|
|
||||||
end := now.UTC().Truncate(time.Minute)
|
|
||||||
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
|
||||||
}
|
|
||||||
|
|
||||||
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
|
||||||
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
|
|
||||||
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
|
||||||
w := cur.WindowHours
|
|
||||||
if w <= 0 {
|
|
||||||
w = auditWindowHours
|
|
||||||
}
|
|
||||||
if empty {
|
|
||||||
if w *= 2; w > auditWindowMaxHours {
|
|
||||||
w = auditWindowMaxHours
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
w = auditWindowHours
|
|
||||||
}
|
|
||||||
end := cur.Start
|
|
||||||
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
|
||||||
return cur, true
|
|
||||||
}
|
|
||||||
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
|
||||||
// 表示已回溯到保留期尽头,无更早数据。
|
|
||||||
type AuditBatchResult struct {
|
|
||||||
Items []AuditEvent
|
|
||||||
Cursor *AuditCursor
|
|
||||||
Exhausted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
|
||||||
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
|
|
||||||
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
|
||||||
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
|
||||||
ac, err := c.auditClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return AuditBatchResult{}, err
|
|
||||||
}
|
|
||||||
res := AuditBatchResult{Items: []AuditEvent{}}
|
|
||||||
windowHasKept := false
|
|
||||||
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
|
|
||||||
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
|
|
||||||
if err != nil {
|
|
||||||
return AuditBatchResult{}, err
|
|
||||||
}
|
|
||||||
before := len(res.Items)
|
|
||||||
res.Items = appendKeptAuditEvents(res.Items, items)
|
|
||||||
windowHasKept = windowHasKept || len(res.Items) > before
|
|
||||||
if next != "" {
|
|
||||||
cur.Page = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextCur, done := cur.advance(time.Now(), !windowHasKept)
|
|
||||||
if done {
|
|
||||||
res.Exhausted = true
|
|
||||||
sortAuditEvents(res.Items)
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
cur, windowHasKept = nextCur, false
|
|
||||||
}
|
|
||||||
sortAuditEvents(res.Items)
|
|
||||||
res.Cursor = &cur
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listAuditPage 拉取当前游标位置的一页原始事件。
|
|
||||||
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
|
|
||||||
req := audit.ListEventsRequest{
|
req := audit.ListEventsRequest{
|
||||||
CompartmentId: &tenancyOCID,
|
CompartmentId: &tenancyOCID,
|
||||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||||
@@ -214,42 +399,18 @@ func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("list audit events: %w", err)
|
return nil, "", fmt.Errorf("list audit events: %w", err)
|
||||||
}
|
}
|
||||||
return resp.Items, deref(resp.OpcNextPage), nil
|
items := make([]AuditEvent, 0, len(resp.Items))
|
||||||
|
for _, ev := range resp.Items {
|
||||||
|
out := toAuditEvent(ev)
|
||||||
|
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||||
|
out.Raw = raw
|
||||||
|
}
|
||||||
|
items = append(items, out)
|
||||||
|
}
|
||||||
|
return items, deref(resp.OpcNextPage), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
// toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||||
var auditInternalCIDRs = func() []*net.IPNet {
|
|
||||||
out := make([]*net.IPNet, 0, 4)
|
|
||||||
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
|
||||||
_, block, _ := net.ParseCIDR(cidr)
|
|
||||||
out = append(out, block)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}()
|
|
||||||
|
|
||||||
// keepAuditEvent 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗),
|
|
||||||
// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调;
|
|
||||||
// 无 IP 的事件(控制面内部)保留。
|
|
||||||
func keepAuditEvent(ev AuditEvent) bool {
|
|
||||||
if ev.EventName == "SummarizeMetricsData" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if ev.IPAddress == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(ev.IPAddress)
|
|
||||||
if ip == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, block := range auditInternalCIDRs {
|
|
||||||
if block.Contains(ip) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
|
||||||
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||||
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||||
if ev.EventTime != nil {
|
if ev.EventTime != nil {
|
||||||
@@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchAuditPage 拉取游标窗口内按 datetime 倒序的一页审计事件(已映射未过滤)。
|
||||||
|
func searchAuditPage(ctx context.Context, sc loggingsearch.LogSearchClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
req := loggingsearch.SearchLogsRequest{
|
||||||
|
SearchLogsDetails: loggingsearch.SearchLogsDetails{
|
||||||
|
TimeStart: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||||
|
TimeEnd: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
|
||||||
|
SearchQuery: common.String(auditSearchQuery(tenancyOCID, cur.Q)),
|
||||||
|
},
|
||||||
|
Limit: common.Int(auditSearchPageLimit),
|
||||||
|
}
|
||||||
|
if cur.Page != "" {
|
||||||
|
req.Page = &cur.Page
|
||||||
|
}
|
||||||
|
resp, err := sc.SearchLogs(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("search audit logs: %w", err)
|
||||||
|
}
|
||||||
|
items := make([]AuditEvent, 0, len(resp.Results))
|
||||||
|
for _, r := range resp.Results {
|
||||||
|
if ev, ok := toSearchAuditEvent(r); ok {
|
||||||
|
items = append(items, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, deref(resp.OpcNextPage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||||
|
var auditInternalCIDRs = func() []*net.IPNet {
|
||||||
|
out := make([]*net.IPNet, 0, 4)
|
||||||
|
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||||
|
_, block, _ := net.ParseCIDR(cidr)
|
||||||
|
out = append(out, block)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// keepAuditEvent 保留有展示价值的事件:SummarizeMetricsData 已在检索语句里
|
||||||
|
// 先滤(此处兜底),内网地址发起的服务互调用 CIDR 判断(查询语言不便表达);
|
||||||
|
// 无 IP 的事件(控制面内部)保留。
|
||||||
|
func keepAuditEvent(ev AuditEvent) bool {
|
||||||
|
if ev.EventName == "SummarizeMetricsData" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ev.IPAddress == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(ev.IPAddress)
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, block := range auditInternalCIDRs {
|
||||||
|
if block.Contains(ip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAuditContent 是 _Audit 日志 logContent 的字段投影,只取列表展示所需;
|
||||||
|
// identity / request / response 可能为 null,零值即缺省。
|
||||||
|
type searchAuditContent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Time *time.Time `json:"time"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Data struct {
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
ResourceName string `json:"resourceName"`
|
||||||
|
CompartmentName string `json:"compartmentName"`
|
||||||
|
Identity struct {
|
||||||
|
PrincipalName string `json:"principalName"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
} `json:"identity"`
|
||||||
|
Request struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"request"`
|
||||||
|
Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"response"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSearchAuditEvent 把日志搜索结果压平为列表 DTO;Raw 即 logContent 原文。
|
||||||
|
// 结构不符的条目丢弃(返回 false),不因单条脏数据整页失败。
|
||||||
|
func toSearchAuditEvent(r loggingsearch.SearchResult) (AuditEvent, bool) {
|
||||||
|
if r.Data == nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
var hit struct {
|
||||||
|
LogContent json.RawMessage `json:"logContent"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &hit); err != nil || len(hit.LogContent) == 0 {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
var content searchAuditContent
|
||||||
|
if err := json.Unmarshal(hit.LogContent, &content); err != nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
ev := searchContentToEvent(content)
|
||||||
|
ev.Raw = hit.LogContent
|
||||||
|
return ev, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchContentToEvent 把投影字段填入列表 DTO;Raw 由调用方设置。
|
||||||
|
func searchContentToEvent(c searchAuditContent) AuditEvent {
|
||||||
|
return AuditEvent{
|
||||||
|
EventId: c.ID,
|
||||||
|
EventTime: c.Time,
|
||||||
|
EventName: c.Data.EventName,
|
||||||
|
Source: c.Source,
|
||||||
|
ResourceName: c.Data.ResourceName,
|
||||||
|
CompartmentName: c.Data.CompartmentName,
|
||||||
|
PrincipalName: c.Data.Identity.PrincipalName,
|
||||||
|
IPAddress: c.Data.Identity.IPAddress,
|
||||||
|
Status: c.Data.Response.Status,
|
||||||
|
RequestAction: c.Data.Request.Action,
|
||||||
|
RequestPath: c.Data.Request.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||||
func sortAuditEvents(items []AuditEvent) {
|
func sortAuditEvents(items []AuditEvent) {
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
|||||||
+294
-27
@@ -1,14 +1,262 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。
|
||||||
|
var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`)
|
||||||
|
|
||||||
|
func TestIsSearchQuotaZero(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"配额为零真实报错", quotaZeroErr, true},
|
||||||
|
{"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false},
|
||||||
|
{"其他错误", errors.New("service unavailable"), false},
|
||||||
|
{"nil", nil, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isSearchQuotaZero(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchFallback(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
searchCalls, auditCalls := 0, 0
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
searchCalls++
|
||||||
|
return nil, "", quotaZeroErr
|
||||||
|
},
|
||||||
|
audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
auditCalls++
|
||||||
|
if cur.M != auditModeFallback || cur.Page != "" {
|
||||||
|
t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur)
|
||||||
|
}
|
||||||
|
ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et}
|
||||||
|
return []AuditEvent{ev}, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("配额为零应回退成功, got %v", err)
|
||||||
|
}
|
||||||
|
if searchCalls != 1 {
|
||||||
|
t.Fatalf("Search 只应试错一次, got %d", searchCalls)
|
||||||
|
}
|
||||||
|
if len(res.Items) < 3 || auditCalls < 3 {
|
||||||
|
t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls)
|
||||||
|
}
|
||||||
|
if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours {
|
||||||
|
t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("回退模式游标不应再调用 Search 通道")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cur := NewAuditCursor(time.Now()).toFallback()
|
||||||
|
if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil {
|
||||||
|
t.Fatalf("回退模式续查失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchSearchErrorNoFallback(t *testing.T) {
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return nil, "", errors.New("search audit logs: timeout")
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("普通错误不应触发回退")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil {
|
||||||
|
t.Fatal("普通错误应原样上抛")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAuditTerm(t *testing.T) {
|
||||||
|
login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"}
|
||||||
|
noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"}
|
||||||
|
items := []AuditEvent{login, noise}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cur AuditCursor
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"无关键字原样放行", AuditCursor{}, 2},
|
||||||
|
{"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1},
|
||||||
|
{"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want {
|
||||||
|
t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchSearchTermPrecision(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
// 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login)
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return []AuditEvent{
|
||||||
|
{EventId: "hit", EventName: "InteractiveLogin", EventTime: &et},
|
||||||
|
{EventId: "noise1", EventName: "ListRecommendations", EventTime: &et},
|
||||||
|
{EventId: "noise2", EventName: "SearchLogs", EventTime: &et},
|
||||||
|
}, "", nil
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("Search 正常时不应走回退")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cur := NewAuditCursor(time.Now())
|
||||||
|
cur.Q = "login"
|
||||||
|
res, err := listAuditBatch(context.Background(), f, cur, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listAuditBatch() err = %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].EventId != "hit" {
|
||||||
|
t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesAuditTerm(t *testing.T) {
|
||||||
|
ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
q string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"不区分大小写", "listvnic", true},
|
||||||
|
{"通配分段都出现", "List*Attachments", true},
|
||||||
|
{"资源名命中", "WEB-1", true},
|
||||||
|
{"未命中", "TerminateInstance", false},
|
||||||
|
{"通配缺段不命中", "List*Volume", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := matchesAuditTerm(ev, tc.q); got != tc.want {
|
||||||
|
t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。
|
||||||
|
func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult {
|
||||||
|
t.Helper()
|
||||||
|
var v interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||||
|
t.Fatalf("fixture 不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
return loggingsearch.SearchResult{Data: &v}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSearchAuditEvent(t *testing.T) {
|
||||||
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
wantOK bool
|
||||||
|
want AuditEvent
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "全字段齐全",
|
||||||
|
data: `{"datetime":1783074600000,"logContent":{
|
||||||
|
"id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi",
|
||||||
|
"data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod",
|
||||||
|
"identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"},
|
||||||
|
"request":{"action":"DELETE","path":"/20160918/instances/ocid1..."},
|
||||||
|
"response":{"status":"204"}}}}`,
|
||||||
|
wantOK: true,
|
||||||
|
want: AuditEvent{
|
||||||
|
EventId: "evt-abc",
|
||||||
|
EventTime: &eventTime,
|
||||||
|
EventName: "TerminateInstance",
|
||||||
|
Source: "ComputeApi",
|
||||||
|
ResourceName: "web-1",
|
||||||
|
CompartmentName: "prod",
|
||||||
|
PrincipalName: "api-admin",
|
||||||
|
IPAddress: "1.2.3.4",
|
||||||
|
Status: "204",
|
||||||
|
RequestAction: "DELETE",
|
||||||
|
RequestPath: "/20160918/instances/ocid1...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identity/request/response 为 null 时只保留信封字段",
|
||||||
|
data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi",
|
||||||
|
"data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`,
|
||||||
|
wantOK: true,
|
||||||
|
want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "缺 logContent 丢弃",
|
||||||
|
data: `{"datetime":1783074600000}`,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logContent 结构不符丢弃",
|
||||||
|
data: `{"logContent":"plain-text"}`,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data))
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got.Raw) == 0 {
|
||||||
|
t.Fatalf("Raw 应携带 logContent 原文")
|
||||||
|
}
|
||||||
|
if !auditEventEqual(got, tt.want) {
|
||||||
|
t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSearchAuditEventNilData(t *testing.T) {
|
||||||
|
if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok {
|
||||||
|
t.Fatal("Data 为 nil 应丢弃")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToAuditEvent(t *testing.T) {
|
func TestToAuditEvent(t *testing.T) {
|
||||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{
|
want: AuditEvent{
|
||||||
EventId: "evt-abc",
|
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
|
||||||
EventTime: &eventTime,
|
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
|
||||||
EventName: "TerminateInstance",
|
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
|
||||||
Source: "ComputeApi",
|
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
|
||||||
ResourceName: "web-1",
|
|
||||||
CompartmentName: "prod",
|
|
||||||
PrincipalName: "api-admin",
|
|
||||||
IPAddress: "1.2.3.4",
|
|
||||||
Status: "204",
|
|
||||||
RequestAction: "DELETE",
|
|
||||||
RequestPath: "/20160918/instances/ocid1...",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Data 为 nil 时只保留信封字段",
|
|
||||||
ev: audit.AuditEvent{
|
|
||||||
Source: common.String("VcnApi"),
|
|
||||||
EventTime: &common.SDKTime{Time: eventTime},
|
|
||||||
},
|
|
||||||
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "嵌套局部 nil 各自安全跳过",
|
name: "嵌套局部 nil 各自安全跳过",
|
||||||
ev: audit.AuditEvent{
|
ev: audit.AuditEvent{
|
||||||
Data: &audit.Data{
|
Data: &audit.Data{
|
||||||
EventName: common.String("GetInstance"),
|
EventName: common.String("GetInstance"),
|
||||||
Identity: nil,
|
|
||||||
Request: &audit.Request{Path: common.String("/instances")},
|
Request: &audit.Request{Path: common.String("/instances")},
|
||||||
Response: nil,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||||
},
|
},
|
||||||
{
|
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
|
||||||
name: "空事件全部零值",
|
|
||||||
ev: audit.AuditEvent{},
|
|
||||||
want: AuditEvent{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuditSearchQuery(t *testing.T) {
|
||||||
|
const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'`
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
term string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"无关键字", "", prefix + ` | sort by datetime desc`},
|
||||||
|
{"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`},
|
||||||
|
{"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`},
|
||||||
|
{"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want {
|
||||||
|
t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeAuditTerm(t *testing.T) {
|
||||||
|
if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" {
|
||||||
|
t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("a", 300)
|
||||||
|
if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen {
|
||||||
|
t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||||
func auditEventEqual(a, b AuditEvent) bool {
|
func auditEventEqual(a, b AuditEvent) bool {
|
||||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||||
@@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
Start: now.Add(-24 * time.Hour),
|
Start: now.Add(-24 * time.Hour),
|
||||||
End: now,
|
End: now,
|
||||||
WindowHours: 24,
|
WindowHours: 24,
|
||||||
|
Q: "kw",
|
||||||
}
|
}
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||||
{"空窗倍增", base, true, 48, false},
|
{"空窗倍增", base, true, 48, false},
|
||||||
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
|
{"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false},
|
||||||
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
||||||
|
{"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false},
|
||||||
|
{"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false},
|
||||||
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
if next.Page != "" {
|
if next.Page != "" {
|
||||||
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
||||||
}
|
}
|
||||||
|
if next.Q != tc.cur.Q {
|
||||||
|
t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q)
|
||||||
|
}
|
||||||
|
if next.M != tc.cur.M {
|
||||||
|
t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/ospgateway"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
|
||||||
|
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
|
||||||
|
|
||||||
|
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
|
||||||
|
type Invoice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InternalID string `json:"internalId"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
IsPaid bool `json:"isPaid"`
|
||||||
|
IsPayable bool `json:"isPayable"`
|
||||||
|
IsPaymentFailed bool `json:"isPaymentFailed"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
AmountDue float64 `json:"amountDue"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TimeInvoice *time.Time `json:"timeInvoice"`
|
||||||
|
TimeDue *time.Time `json:"timeDue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceLine 是发票内一行费用明细。
|
||||||
|
type InvoiceLine struct {
|
||||||
|
Product string `json:"product"`
|
||||||
|
OrderNo string `json:"orderNo"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TimeStart *time.Time `json:"timeStart"`
|
||||||
|
TimeEnd *time.Time `json:"timeEnd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
|
||||||
|
type PaymentMethod struct {
|
||||||
|
Method string `json:"method"` // CREDIT_CARD / PAYPAL
|
||||||
|
CardType string `json:"cardType,omitempty"`
|
||||||
|
LastDigits string `json:"lastDigits,omitempty"`
|
||||||
|
NameOnCard string `json:"nameOnCard,omitempty"`
|
||||||
|
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PayerName string `json:"payerName,omitempty"`
|
||||||
|
BillingAgreement string `json:"billingAgreement,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
|
||||||
|
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
|
||||||
|
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
|
||||||
|
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
|
||||||
|
return v.(string), nil
|
||||||
|
}
|
||||||
|
info, err := c.ValidateKey(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve osp home region: %w", err)
|
||||||
|
}
|
||||||
|
r, ok := RegionByKey(info.HomeRegionKey)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
|
||||||
|
}
|
||||||
|
if cred.TenancyOCID != "" {
|
||||||
|
c.homeRegions.Store(cred.TenancyOCID, r.Name)
|
||||||
|
}
|
||||||
|
return r.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
|
||||||
|
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
|
||||||
|
region, err := c.ospHomeRegion(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return ospgateway.InvoiceServiceClient{}, "", err
|
||||||
|
}
|
||||||
|
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
ic.SetRegion(region)
|
||||||
|
return ic, region, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
|
||||||
|
// (按 timeInvoice 窗口过滤),为 0 拉全量。
|
||||||
|
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
|
||||||
|
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req := ospgateway.ListInvoicesRequest{
|
||||||
|
OspHomeRegion: ®ion,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Limit: common.Int(ospPageLimit),
|
||||||
|
}
|
||||||
|
if year > 0 {
|
||||||
|
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
}
|
||||||
|
var out []Invoice
|
||||||
|
for {
|
||||||
|
resp, err := ic.ListInvoices(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list invoices: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
out = append(out, toInvoice(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(out), nil
|
||||||
|
}
|
||||||
|
req.Page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
|
||||||
|
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
|
||||||
|
const ospPageLimit = 100
|
||||||
|
|
||||||
|
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
|
||||||
|
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
|
||||||
|
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []InvoiceLine
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
|
||||||
|
OspHomeRegion: ®ion,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
InternalInvoiceId: &internalInvoiceID,
|
||||||
|
Page: page,
|
||||||
|
Limit: common.Int(ospPageLimit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list invoice lines: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
out = append(out, toInvoiceLine(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(out), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
|
||||||
|
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
|
||||||
|
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
|
||||||
|
OspHomeRegion: ®ion,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
InternalInvoiceId: &internalInvoiceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download invoice pdf: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Content.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download invoice pdf: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxBytes {
|
||||||
|
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
|
||||||
|
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
|
||||||
|
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
|
||||||
|
OspHomeRegion: ®ion,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
InternalInvoiceId: &internalInvoiceID,
|
||||||
|
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pay invoice: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
|
||||||
|
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
|
||||||
|
region, err := c.ospHomeRegion(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new osp subscription client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
sc.SetRegion(region)
|
||||||
|
var out []PaymentMethod
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
|
||||||
|
OspHomeRegion: ®ion,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list payment methods: %w", err)
|
||||||
|
}
|
||||||
|
for _, sub := range resp.Items {
|
||||||
|
out = appendPaymentMethods(out, sub.PaymentOptions)
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(out), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
|
||||||
|
inv := Invoice{
|
||||||
|
ID: deref(v.InvoiceId),
|
||||||
|
InternalID: deref(v.InternalInvoiceId),
|
||||||
|
Number: deref(v.InvoiceNumber),
|
||||||
|
Type: string(v.InvoiceType),
|
||||||
|
Status: string(v.InvoiceStatus),
|
||||||
|
Amount: money(v.InvoiceAmount),
|
||||||
|
AmountDue: money(v.InvoiceAmountDue),
|
||||||
|
TimeInvoice: sdkTime(v.TimeInvoice),
|
||||||
|
TimeDue: sdkTime(v.TimeInvoiceDue),
|
||||||
|
}
|
||||||
|
if v.IsPaid != nil {
|
||||||
|
inv.IsPaid = *v.IsPaid
|
||||||
|
}
|
||||||
|
if v.IsPayable != nil {
|
||||||
|
inv.IsPayable = *v.IsPayable
|
||||||
|
}
|
||||||
|
if v.IsPaymentFailed != nil {
|
||||||
|
inv.IsPaymentFailed = *v.IsPaymentFailed
|
||||||
|
}
|
||||||
|
if v.Currency != nil {
|
||||||
|
inv.Currency = deref(v.Currency.CurrencyCode)
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
|
||||||
|
line := InvoiceLine{
|
||||||
|
Product: deref(v.Product),
|
||||||
|
OrderNo: deref(v.OrderNo),
|
||||||
|
Quantity: money(v.Quantity),
|
||||||
|
UnitPrice: money(v.NetUnitPrice),
|
||||||
|
Total: money(v.TotalPrice),
|
||||||
|
TimeStart: sdkTime(v.TimeStart),
|
||||||
|
TimeEnd: sdkTime(v.TimeEnd),
|
||||||
|
}
|
||||||
|
if v.Currency != nil {
|
||||||
|
line.Currency = deref(v.Currency.CurrencyCode)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
|
||||||
|
for _, opt := range opts {
|
||||||
|
switch v := opt.(type) {
|
||||||
|
case ospgateway.CreditCardPaymentOption:
|
||||||
|
out = append(out, creditCardMethod(v))
|
||||||
|
case *ospgateway.CreditCardPaymentOption:
|
||||||
|
out = append(out, creditCardMethod(*v))
|
||||||
|
case ospgateway.PaypalPaymentOption:
|
||||||
|
out = append(out, paypalMethod(v))
|
||||||
|
case *ospgateway.PaypalPaymentOption:
|
||||||
|
out = append(out, paypalMethod(*v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
|
||||||
|
return PaymentMethod{
|
||||||
|
Method: "CREDIT_CARD",
|
||||||
|
CardType: string(v.CreditCardType),
|
||||||
|
LastDigits: deref(v.LastDigits),
|
||||||
|
NameOnCard: deref(v.NameOnCard),
|
||||||
|
TimeExpiration: sdkTime(v.TimeExpiration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
|
||||||
|
return PaymentMethod{
|
||||||
|
Method: "PAYPAL",
|
||||||
|
Email: deref(v.EmailAddress),
|
||||||
|
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
|
||||||
|
BillingAgreement: deref(v.ExtBillingAgreementId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
|
||||||
|
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
|
||||||
|
func money(p *float32) float64 {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Round(float64(*p)*10000) / 10000
|
||||||
|
}
|
||||||
@@ -32,8 +32,10 @@ func NewCachedClient(inner Client) *CachedClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
||||||
|
// compartment 必须参与键值:列表查询按 EffectiveCompartment 过滤,
|
||||||
|
// 同租户切换区间时若共用键会串到上一个区间的缓存结果。
|
||||||
func ckey(cred Credentials, parts ...string) string {
|
func ckey(cred Credentials, parts ...string) string {
|
||||||
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
|
return cred.TenancyOCID + "|" + cred.CompartmentID + "|" + strings.Join(parts, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
// bust 写操作成功后失效该租户全部读缓存。
|
// bust 写操作成功后失效该租户全部读缓存。
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ func TestCachedClientHitAndIsolation(t *testing.T) {
|
|||||||
if inner.instCalls != 3 {
|
if inner.instCalls != 3 {
|
||||||
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
|
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) {
|
func TestCachedClientWriteBusts(t *testing.T) {
|
||||||
|
|||||||
+53
-4
@@ -50,6 +50,13 @@ type Client interface {
|
|||||||
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
|
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
|
||||||
// GetSubscription 查询单个订阅的完整信息。
|
// GetSubscription 查询单个订阅的完整信息。
|
||||||
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
|
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 列出租户在目标区域可用的实例规格(带缓存)。
|
||||||
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
|
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
|
||||||
// ListImages 列出可用的实例镜像。
|
// ListImages 列出可用的实例镜像。
|
||||||
@@ -91,6 +98,36 @@ type Client interface {
|
|||||||
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
|
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
|
||||||
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
|
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
|
||||||
ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error)
|
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)
|
AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error)
|
||||||
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
|
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
|
||||||
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
|
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
|
||||||
@@ -102,10 +139,12 @@ type Client interface {
|
|||||||
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
||||||
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, 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)
|
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 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路);
|
||||||
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
|
// wait 是上游无响应预算(整请求总超时),multi-agent/搜索类模型需远超 SDK 默认 60s。
|
||||||
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
|
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error)
|
||||||
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, 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 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
|
||||||
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
||||||
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
||||||
@@ -150,6 +189,10 @@ type Client interface {
|
|||||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
||||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
|
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)
|
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)
|
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []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)
|
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
|
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
||||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, 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)
|
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
||||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName 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
|
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
||||||
@@ -184,6 +231,8 @@ type Client interface {
|
|||||||
type RealClient struct {
|
type RealClient struct {
|
||||||
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||||
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||||
|
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
|
||||||
|
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient 返回生产使用的真实客户端。
|
// NewClient 返回生产使用的真实客户端。
|
||||||
|
|||||||
+38
-13
@@ -14,15 +14,16 @@ import (
|
|||||||
type CostQuery struct {
|
type CostQuery struct {
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
Granularity string // DAILY / MONTHLY
|
Granularity string // HOURLY / DAILY / MONTHLY
|
||||||
QueryType string // COST / USAGE
|
QueryType string // COST / USAGE
|
||||||
GroupBy string // service / skuName / region 等维度
|
GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CostItem 是一个时间桶内某分组维度的成本或用量。
|
// CostItem 是一个时间桶内某分组维度的成本或用量。
|
||||||
type CostItem struct {
|
type CostItem struct {
|
||||||
TimeStart *time.Time `json:"timeStart"`
|
TimeStart *time.Time `json:"timeStart"`
|
||||||
GroupValue string `json:"groupValue"`
|
GroupValue string `json:"groupValue"`
|
||||||
|
SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName)
|
||||||
ComputedAmount float32 `json:"computedAmount"`
|
ComputedAmount float32 `json:"computedAmount"`
|
||||||
ComputedQuantity float32 `json:"computedQuantity"`
|
ComputedQuantity float32 `json:"computedQuantity"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
@@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari
|
|||||||
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
|
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
|
||||||
Granularity: granularity,
|
Granularity: granularity,
|
||||||
QueryType: queryType,
|
QueryType: queryType,
|
||||||
GroupBy: []string{q.GroupBy},
|
GroupBy: splitGroupBy(q.GroupBy),
|
||||||
}, nil
|
}, 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 {
|
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
||||||
out := CostItem{
|
out := CostItem{
|
||||||
Currency: deref(item.Currency),
|
Currency: deref(item.Currency),
|
||||||
@@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
|||||||
if item.ComputedQuantity != nil {
|
if item.ComputedQuantity != nil {
|
||||||
out.ComputedQuantity = *item.ComputedQuantity
|
out.ComputedQuantity = *item.ComputedQuantity
|
||||||
}
|
}
|
||||||
switch strings.ToLower(groupBy) {
|
dims := splitGroupBy(groupBy)
|
||||||
case "skuname":
|
if len(dims) == 0 {
|
||||||
out.GroupValue = deref(item.SkuName)
|
dims = []string{"service"}
|
||||||
case "region":
|
}
|
||||||
out.GroupValue = deref(item.Region)
|
out.GroupValue = dimValue(item, dims[0])
|
||||||
case "compartmentname":
|
if len(dims) > 1 {
|
||||||
out.GroupValue = deref(item.CompartmentName)
|
out.SubValue = dimValue(item, dims[1])
|
||||||
default:
|
|
||||||
out.GroupValue = deref(item.Service)
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。
|
||||||
|
const domainImagesPath = "/storage/v1/Images"
|
||||||
|
|
||||||
|
// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl
|
||||||
|
// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient
|
||||||
|
// 发裸 multipart 请求,OCI 签名与代理配置直接复用。
|
||||||
|
func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||||
|
return parseImageUploadResponse(resp, callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。
|
||||||
|
func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||||
|
return finishDomainImageDelete(resp, callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) {
|
||||||
|
body, contentType, err := imageMultipart(fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image upload request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) {
|
||||||
|
u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image delete URL: %w", err)
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("fileName", fileName)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image delete request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。
|
||||||
|
func imageMultipart(fileName string, data []byte) ([]byte, string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
fw, err := w.CreateFormFile("file", fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(data); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.WriteField("fileName", fileName); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), w.FormDataContentType(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。
|
||||||
|
func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer drainAndClose(resp.Body)
|
||||||
|
}
|
||||||
|
if callErr != nil {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: %w", callErr)
|
||||||
|
}
|
||||||
|
if resp == nil || resp.Body == nil {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: empty response")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("read image upload response: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) > 1<<20 {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: response exceeds 1MB")
|
||||||
|
}
|
||||||
|
return decodeImageUploadResponse(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeImageUploadResponse(data []byte) (string, string, error) {
|
||||||
|
var out struct {
|
||||||
|
FileURL string `json:"fileUrl"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return "", "", fmt.Errorf("parse image upload response: %w", err)
|
||||||
|
}
|
||||||
|
if out.FileURL == "" || out.FileName == "" {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName")
|
||||||
|
}
|
||||||
|
return out.FileURL, out.FileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishDomainImageDelete(resp *http.Response, callErr error) error {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer drainAndClose(resp.Body)
|
||||||
|
}
|
||||||
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if callErr != nil {
|
||||||
|
return fmt.Errorf("delete domain image: %w", callErr)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("delete domain image: empty response")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。
|
||||||
|
func drainAndClose(body io.ReadCloser) {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
|
||||||
|
_ = body.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trackingReadCloser struct {
|
||||||
|
io.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMultipartFile struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *trackingReadCloser) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) {
|
||||||
|
reader := &trackingReadCloser{Reader: strings.NewReader(body)}
|
||||||
|
return &http.Response{StatusCode: status, Body: reader}, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) {
|
||||||
|
t.Helper()
|
||||||
|
_, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse content type: %v", err)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
|
||||||
|
fields, files := map[string]string{}, map[string]testMultipartFile{}
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("next part: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(part)
|
||||||
|
if part.FileName() == "" {
|
||||||
|
fields[part.FormName()] = string(data)
|
||||||
|
} else {
|
||||||
|
files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields, files
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageMultipartIncludesFileAndName(t *testing.T) {
|
||||||
|
fileName := "idp-icon-00112233445566778899aabbccddeeff.png"
|
||||||
|
body, contentType, err := imageMultipart(fileName, []byte("image-data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imageMultipart: %v", err)
|
||||||
|
}
|
||||||
|
fields, files := readMultipartFields(t, body, contentType)
|
||||||
|
if got := fields["fileName"]; got != fileName {
|
||||||
|
t.Errorf("fileName = %q, want %q", got, fileName)
|
||||||
|
}
|
||||||
|
file := files["file"]
|
||||||
|
if file.name != fileName || string(file.data) != "image-data" {
|
||||||
|
t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageUploadResponse(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, body, wantURL, wantName string
|
||||||
|
status int
|
||||||
|
callErr error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false},
|
||||||
|
{"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true},
|
||||||
|
{"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true},
|
||||||
|
{"bad json", `{`, "", "", 200, nil, true},
|
||||||
|
{"http error hides body", `secret-body`, "", "", 400, nil, true},
|
||||||
|
{"call error", `{}`, "", "", 500, errors.New("upstream"), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
resp, body := testImageResponse(tc.status, tc.body)
|
||||||
|
gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr)
|
||||||
|
if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Error("response body was not closed")
|
||||||
|
}
|
||||||
|
if err != nil && strings.Contains(err.Error(), "secret-body") {
|
||||||
|
t.Errorf("error leaked response body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) {
|
||||||
|
fileName := "images/folder/a b+%.png"
|
||||||
|
req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newDomainImageDeleteRequest: %v", err)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath {
|
||||||
|
t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath)
|
||||||
|
}
|
||||||
|
if got := req.URL.Query().Get("fileName"); got != fileName {
|
||||||
|
t.Errorf("decoded fileName = %q, want %q", got, fileName)
|
||||||
|
}
|
||||||
|
if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") {
|
||||||
|
t.Errorf("raw query is not encoded: %q", req.URL.RawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishDomainImageDelete(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
callErr error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"deleted", http.StatusNoContent, nil, false},
|
||||||
|
{"already gone", http.StatusNotFound, errors.New("not found"), false},
|
||||||
|
{"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
resp, body := testImageResponse(tc.status, "response")
|
||||||
|
err := finishDomainImageDelete(resp, tc.callErr)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("err = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Error("response body was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-14
@@ -3,6 +3,7 @@ package oci
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
|
|||||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||||
|
|
||||||
|
const idpCreateRollbackWait = 15 * time.Second
|
||||||
|
|
||||||
|
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
|
||||||
|
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
|
||||||
|
|
||||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||||
type IdentityProviderInfo struct {
|
type IdentityProviderInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
|
|||||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
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 的输入;零值即控制台默认行为:
|
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||||
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
|||||||
return idps, nil
|
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) {
|
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
}
|
}
|
||||||
|
return createSamlIdentityProvider(ctx, dc, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||||
|
var err error
|
||||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||||
}
|
}
|
||||||
if in.JitEnabled {
|
info := toIdpInfo(resp.IdentityProvider)
|
||||||
|
if !in.JitEnabled {
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||||
return toIdpInfo(resp.IdentityProvider), err
|
return rollbackIncompleteIdp(ctx, dc, info, err)
|
||||||
}
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
|
||||||
|
if info.ID == "" {
|
||||||
|
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
|
||||||
|
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||||
}
|
}
|
||||||
return toIdpInfo(resp.IdentityProvider), nil
|
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
|
||||||
|
defer cancel()
|
||||||
|
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
|
||||||
|
IdentityProviderId: &info.ID,
|
||||||
|
})
|
||||||
|
if rollbackErr == nil {
|
||||||
|
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
|
||||||
|
}
|
||||||
|
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
|
||||||
|
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||||
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
|||||||
|
|
||||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
// 按 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])
|
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||||||
count := len(adminGroupNames)
|
count := len(adminGroupNames)
|
||||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||||
@@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
// 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
|
ref := idp.JitUserProvAttributes
|
||||||
if ref == nil || ref.Value == nil {
|
if ref == nil || ref.Value == nil {
|
||||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
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。
|
// 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) {
|
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||||
returns := make([]interface{}, 0, len(items))
|
returns := make([]interface{}, 0, len(items)+1)
|
||||||
changed := false
|
changed, seen := false, false
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
name, value := deref(item.Name), deref(item.Value)
|
name, value := deref(item.Name), deref(item.Value)
|
||||||
if name == "SamlIDPs" {
|
if name == "SamlIDPs" {
|
||||||
|
seen = true
|
||||||
next, ok, err := toggleJSONList(value, idpID, show)
|
next, ok, err := toggleJSONList(value, idpID, show)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
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})
|
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
|
return returns, changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err := fetchSamlMetadata(ctx, url)
|
body, status, err := fetchSamlMetadata(ctx, cred, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err = fetchSamlMetadata(ctx, url)
|
body, status, err = fetchSamlMetadata(ctx, cred, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
|
||||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
// 防异常缓慢或超大响应长期占住请求与内存。
|
||||||
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
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
|
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 开启域设置「访问签名证书」公开访问。
|
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,15 +149,17 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;
|
||||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16:
|
||||||
|
// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。
|
||||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
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",
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
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
|
return http.StatusOK, nil
|
||||||
}
|
}
|
||||||
if status, ok := ServiceStatus(err); ok {
|
if status, ok := ServiceStatus(err); ok {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
)
|
)
|
||||||
@@ -13,18 +14,19 @@ import (
|
|||||||
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
|
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
|
||||||
const compatResponsesLimit = int64(8 << 20)
|
const compatResponsesLimit = int64(8 << 20)
|
||||||
|
|
||||||
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
// dispatcherWithTimeout 把 dispatcher 换成指定总超时的拷贝(保留 Transport,
|
||||||
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
// 代理链路不受影响);timeout=0 表示无总超时(流式读 body 不能有总时限)。
|
||||||
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
// 非 *http.Client 的自定义 dispatcher 保持原样,维持既有超时行为。
|
||||||
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
|
func dispatcherWithTimeout(d common.HTTPRequestDispatcher, timeout time.Duration) common.HTTPRequestDispatcher {
|
||||||
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
|
hc, ok := d.(*http.Client)
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
if !ok {
|
||||||
if err != nil {
|
return d
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
client := ic.BaseClient
|
return &http.Client{Transport: hc.Transport, Timeout: timeout}
|
||||||
common.UpdateEndpointTemplateForOptions(&client)
|
}
|
||||||
common.SetMissingTemplateParams(&client)
|
|
||||||
|
// 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))
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build compat responses request: %w", err)
|
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("Content-Type", "application/json")
|
||||||
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
||||||
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
||||||
response, err := client.Call(ctx, request)
|
return request, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
|
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
||||||
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
||||||
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
||||||
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
|
// 遵循 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)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -55,19 +50,87 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
|
|||||||
client := ic.BaseClient
|
client := ic.BaseClient
|
||||||
common.UpdateEndpointTemplateForOptions(&client)
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
common.SetMissingTemplateParams(&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 {
|
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)
|
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 err != nil {
|
||||||
if response != nil && response.Body != nil {
|
if response != nil && response.Body != nil {
|
||||||
response.Body.Close()
|
response.Body.Close()
|
||||||
}
|
}
|
||||||
|
cancel()
|
||||||
return nil, err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"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
|
return nil, "", err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
|
payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("read compat speech body: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return payload, response.Header.Get("Content-Type"), nil
|
return payload, response.Header.Get("Content-Type"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type CreateInstanceInput struct {
|
|||||||
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
||||||
SubnetID string // 为空时自动创建 VCN 与子网
|
SubnetID string // 为空时自动创建 VCN 与子网
|
||||||
AssignPublicIP bool
|
AssignPublicIP bool
|
||||||
|
ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP
|
||||||
AssignIpv6 bool
|
AssignIpv6 bool
|
||||||
SSHPublicKey string // 与 RootPassword 互斥
|
SSHPublicKey string // 与 RootPassword 互斥
|
||||||
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
|
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) {
|
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
|
||||||
|
// 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑
|
||||||
|
if in.ReservedPublicIPID != "" {
|
||||||
|
in.AssignPublicIP = false
|
||||||
|
}
|
||||||
details := core.LaunchInstanceDetails{
|
details := core.LaunchInstanceDetails{
|
||||||
CompartmentId: &compartmentID,
|
CompartmentId: &compartmentID,
|
||||||
AvailabilityDomain: &in.AvailabilityDomain,
|
AvailabilityDomain: &in.AvailabilityDomain,
|
||||||
@@ -400,6 +405,7 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sem = make(chan struct{}, 8)
|
sem = make(chan struct{}, 8)
|
||||||
|
primarySeen = make(map[*Instance]bool)
|
||||||
)
|
)
|
||||||
for _, att := range attResp.Items {
|
for _, att := range attResp.Items {
|
||||||
inst, ok := active[deref(att.InstanceId)]
|
inst, ok := active[deref(att.InstanceId)]
|
||||||
@@ -416,16 +422,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
inst.SubnetID = deref(vnicResp.SubnetId)
|
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
|
||||||
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
|
||||||
inst.PublicIP = deref(vnicResp.PublicIp)
|
|
||||||
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(att.VnicId, inst)
|
}(att.VnicId, inst)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
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 {
|
func toInstance(inst core.Instance) Instance {
|
||||||
out := Instance{
|
out := Instance{
|
||||||
ID: deref(inst.Id),
|
ID: deref(inst.Id),
|
||||||
|
|||||||
@@ -5,9 +5,51 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"github.com/oracle/oci-go-sdk/v65/core"
|
"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) {
|
func TestShellSingleQuote(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+170
-61
@@ -2,8 +2,6 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,14 +12,11 @@ import (
|
|||||||
"github.com/oracle/oci-go-sdk/v65/sch"
|
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
|
// 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生;
|
||||||
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
||||||
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
||||||
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
||||||
const (
|
const (
|
||||||
relayTopicPrefix = "ociportal-logs"
|
|
||||||
relayPolicyName = "ociportal-logs-sch"
|
|
||||||
relayConnectorName = "ociportal-logs"
|
|
||||||
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
||||||
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
||||||
)
|
)
|
||||||
@@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er
|
|||||||
return sc, nil
|
return sc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
|
// EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀;
|
||||||
|
// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。
|
||||||
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
||||||
cp, err := c.onsControlClient(cred)
|
cp, err := c.onsControlClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
return res, err
|
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 {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
||||||
CreateTopicDetails: ons.CreateTopicDetails{
|
CreateTopicDetails: ons.CreateTopicDetails{
|
||||||
Name: &name,
|
Name: &name,
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
|
Description: common.String(relayTopicDescNew),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。
|
// findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回。
|
||||||
func relayTopicNewName() (string, error) {
|
// 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。
|
||||||
buf := make([]byte, 4)
|
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) {
|
||||||
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) {
|
|
||||||
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
||||||
for page := 0; page < relayTopicPages; page++ {
|
for page := 0; page < relayTopicPages; page++ {
|
||||||
list, err := cp.ListTopics(ctx, req)
|
list, err := cp.ListTopics(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
||||||
}
|
}
|
||||||
for _, t := range list.Items {
|
if res, ok := matchRelayTopic(list.Items, prefixes); ok {
|
||||||
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
|
return res, true, nil
|
||||||
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
|
|
||||||
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if list.OpcNextPage == nil {
|
if list.OpcNextPage == nil {
|
||||||
break
|
break
|
||||||
@@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient,
|
|||||||
return RelayResource{}, false, nil
|
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 订阅或新建;
|
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
||||||
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
||||||
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
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
|
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
|
// EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名;
|
||||||
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
// 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||||
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
||||||
ic, err := c.identityClientAt(cred, homeRegion)
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
|
res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
refreshRelayPolicyDesc(ctx, ic, res.ID)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
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{
|
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
CompartmentId: &tenancy, Name: common.String(name),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, fmt.Errorf("list policies: %w", err)
|
return RelayResource{}, false, fmt.Errorf("list policies: %w", err)
|
||||||
}
|
}
|
||||||
if len(list.Items) > 0 {
|
if len(list.Items) > 0 {
|
||||||
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
|
it := list.Items[0]
|
||||||
|
return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, 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 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{
|
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
||||||
CreatePolicyDetails: identity.CreatePolicyDetails{
|
CreatePolicyDetails: identity.CreatePolicyDetails{
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Name: common.String(relayPolicyName),
|
Name: common.String(name),
|
||||||
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
|
Description: common.String(relayPolicyDescNew),
|
||||||
Statements: []string{stmt},
|
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
|
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
|
// refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。
|
||||||
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
|
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) {
|
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
||||||
sc, err := c.schClient(cred)
|
sc, err := c.schClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
return res, err
|
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{
|
details := sch.CreateServiceConnectorDetails{
|
||||||
DisplayName: common.String(relayConnectorName),
|
DisplayName: common.String(name),
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
LogGroupId: common.String(relayAuditLogGroup),
|
LogGroupId: common.String(relayAuditLogGroup),
|
||||||
}}},
|
}}},
|
||||||
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
||||||
@@ -254,13 +325,53 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
||||||
}
|
}
|
||||||
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
return waitRelayConnector(ctx, sc, tenancy, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findRelayConnector 按名查找存活 Connector。
|
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
|
||||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
// (如去除 LaunchInstance)后,已建链路经「一键创建」幂等调用原地更新,无需拆除重建。
|
||||||
|
func reconcileRelayCondition(ctx context.Context, sc sch.ServiceConnectorClient, id, condition string) error {
|
||||||
|
got, err := sc.GetServiceConnector(ctx, sch.GetServiceConnectorRequest{ServiceConnectorId: &id})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get service connector: %w", err)
|
||||||
|
}
|
||||||
|
if !relayConditionDiffers(got.Tasks, condition) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details := sch.UpdateServiceConnectorDetails{Tasks: []sch.TaskDetails{}}
|
||||||
|
if condition != "" {
|
||||||
|
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
|
||||||
|
}
|
||||||
|
_, err = sc.UpdateServiceConnector(ctx, sch.UpdateServiceConnectorRequest{
|
||||||
|
ServiceConnectorId: &id, UpdateServiceConnectorDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update service connector condition: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayConditionDiffers 判断现有任务集与期望条件是否不一致:
|
||||||
|
// 期望形态是单条 LogRule 条件;任务数、类型或条件文本不同均视为漂移。
|
||||||
|
func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
|
||||||
|
if want == "" {
|
||||||
|
return len(tasks) > 0
|
||||||
|
}
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rule, ok := tasks[0].(sch.LogRuleTaskDetailsResponse)
|
||||||
|
if !ok || rule.Condition == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *rule.Condition != want
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelayConnector 依次按传入的 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{
|
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||||
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
|
CompartmentId: &tenancy, DisplayName: common.String(name),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||||
@@ -270,11 +381,13 @@ func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
|
|||||||
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return RelayResource{}, false, nil
|
return RelayResource{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
// 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}
|
last := RelayResource{Created: true}
|
||||||
for i := 0; i < relayConnectorPollLimit; i++ {
|
for i := 0; i < relayConnectorPollLimit; i++ {
|
||||||
select {
|
select {
|
||||||
@@ -282,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
|
|||||||
return last, ctx.Err()
|
return last, ctx.Err()
|
||||||
case <-time.After(relayConnectorPollTick):
|
case <-time.After(relayConnectorPollTick):
|
||||||
}
|
}
|
||||||
res, ok, err := findRelayConnector(ctx, sc, tenancy)
|
res, ok, err := findRelayConnector(ctx, sc, tenancy, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return last, err
|
return last, err
|
||||||
}
|
}
|
||||||
@@ -303,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
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
|
return st, err
|
||||||
}
|
}
|
||||||
if st.Topic.ID != "" {
|
if st.Topic.ID != "" {
|
||||||
@@ -318,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
|||||||
return c.relayControlState(ctx, cred, st)
|
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) {
|
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
||||||
sc, err := c.schClient(cred)
|
sc, err := c.schClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
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
|
return st, err
|
||||||
}
|
}
|
||||||
ic, err := c.identityClientAt(cred, "")
|
ic, err := c.identityClientAt(cred, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil {
|
||||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
return st, err
|
||||||
})
|
|
||||||
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)}
|
|
||||||
}
|
}
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package oci
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRelayEventCondition(t *testing.T) {
|
func TestRelayEventCondition(t *testing.T) {
|
||||||
@@ -35,3 +37,31 @@ func TestRelayEventConditionQuoting(t *testing.T) {
|
|||||||
t.Errorf("单引号数 = %d, want 2 (%s)", strings.Count(cond, "'"), cond)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc
|
|||||||
return toVCN(resp.Vcn), nil
|
return toVCN(resp.Vcn), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 /
|
// DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 /
|
||||||
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
|
// 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
|
||||||
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
||||||
vn, err := c.vcnClient(cred, region)
|
vn, err := c.vcnClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
@@ -23,6 +25,27 @@ type ProxySpec struct {
|
|||||||
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
||||||
const proxyClientTimeout = 60 * time.Second
|
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 构造后统一挂出站代理;未关联代理时不动默认配置。
|
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
||||||
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
||||||
func applyProxy(base *common.BaseClient, cred Credentials) {
|
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 {
|
func proxyHTTPClient(p *ProxySpec) *http.Client {
|
||||||
if p == nil || p.Host == "" || p.Port <= 0 {
|
if p == nil || p.Host == "" || p.Port <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if v, ok := proxyClients.Load(*p); ok {
|
||||||
|
return v.(*http.Client)
|
||||||
|
}
|
||||||
tr := transportFor(p)
|
tr := transportFor(p)
|
||||||
if tr == nil {
|
if tr == nil {
|
||||||
return 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
|
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
|
||||||
@@ -49,21 +77,35 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
|
|||||||
return proxyHTTPClient(p)
|
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 走拨号器。
|
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
||||||
func transportFor(p *ProxySpec) *http.Transport {
|
func transportFor(p *ProxySpec) *http.Transport {
|
||||||
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||||
|
dialer := &net.Dialer{Timeout: proxyDialTimeout}
|
||||||
if p.Type == "http" || p.Type == "https" {
|
if p.Type == "http" || p.Type == "https" {
|
||||||
u := &url.URL{Scheme: p.Type, Host: addr}
|
u := &url.URL{Scheme: p.Type, Host: addr}
|
||||||
if p.Username != "" {
|
if p.Username != "" {
|
||||||
u.User = url.UserPassword(p.Username, p.Password)
|
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
|
var auth *proxy.Auth
|
||||||
if p.Username != "" {
|
if p.Username != "" {
|
||||||
auth = &proxy.Auth{User: p.Username, Password: p.Password}
|
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 {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -71,5 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &http.Transport{DialContext: cd.DialContext}
|
tr := pooledTransport()
|
||||||
|
tr.DialContext = cd.DialContext
|
||||||
|
return tr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package oci
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"github.com/oracle/oci-go-sdk/v65/core"
|
"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。
|
// 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) {
|
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||||
vn, err := c.vcnClient(cred, region)
|
vn, err := c.vcnClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil {
|
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
|
||||||
if existing.Lifetime == core.PublicIpLifetimeReserved {
|
}
|
||||||
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
|
|
||||||
|
// ChangeVnicPublicIP 实现 Client:为指定 VNIC 的主私有 IP 更换临时公网 IP(次要网卡场景)。
|
||||||
|
func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
|
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
|
||||||
return "", fmt.Errorf("delete old public ip: %w", err)
|
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
|
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
|
||||||
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
|
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
|
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。
|
// primaryVnic 返回实例的主 VNIC。
|
||||||
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
|
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
|
||||||
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ type ComputeShape struct {
|
|||||||
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
|
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
|
||||||
Gpus int `json:"gpus,omitempty"`
|
Gpus int `json:"gpus,omitempty"`
|
||||||
ProcessorDescription string `json:"processorDescription,omitempty"`
|
ProcessorDescription string `json:"processorDescription,omitempty"`
|
||||||
|
// QuotaNames 是该 shape 对应的配额名(与 Limits 服务 compute limit name 同名),
|
||||||
|
// 前端据此结合 limits 行判定配额与 AD 可用性。
|
||||||
|
QuotaNames []string `json:"quotaNames,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// shapeCacheEntry 是一份 shape 清单的缓存条目。
|
// shapeCacheEntry 是一份 shape 清单的缓存条目。
|
||||||
@@ -92,6 +95,7 @@ func toComputeShape(s core.Shape) ComputeShape {
|
|||||||
BillingType: string(s.BillingType),
|
BillingType: string(s.BillingType),
|
||||||
IsFlexible: s.OcpuOptions != nil,
|
IsFlexible: s.OcpuOptions != nil,
|
||||||
ProcessorDescription: deref(s.ProcessorDescription),
|
ProcessorDescription: deref(s.ProcessorDescription),
|
||||||
|
QuotaNames: s.QuotaNames,
|
||||||
}
|
}
|
||||||
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
|
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
|
||||||
if s.Gpus != nil {
|
if s.Gpus != nil {
|
||||||
|
|||||||
+11
-5
@@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy"
|
|||||||
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
||||||
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
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 策略中一条规则的关键字段。
|
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
||||||
type SignOnRuleInfo struct {
|
type SignOnRuleInfo struct {
|
||||||
ID string `json:"id"`
|
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 {
|
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
||||||
var value interface{} = rules
|
var value interface{} = rules
|
||||||
|
var consent interface{} = true
|
||||||
|
var justification interface{} = "MFA Configured in External IDP"
|
||||||
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
||||||
PolicyId: common.String(ociConsolePolicyID),
|
PolicyId: common.String(ociConsolePolicyID),
|
||||||
PatchOp: identitydomains.PatchOp{
|
PatchOp: identitydomains.PatchOp{
|
||||||
Schemas: []string{scimPatchSchema},
|
Schemas: []string{scimPatchSchema},
|
||||||
Operations: []identitydomains.Operations{{
|
// 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent
|
||||||
Op: identitydomains.OperationsOpReplace,
|
Operations: []identitydomains.Operations{
|
||||||
Path: common.String("rules"),
|
{Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value},
|
||||||
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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -760,6 +760,66 @@ func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentia
|
|||||||
return deleted, nil
|
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。
|
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
||||||
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
||||||
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
|
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
|
||||||
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
|
// 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
|
||||||
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState,
|
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState,
|
||||||
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
|
// 该错误经 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 {
|
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
|
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
|
||||||
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
|
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
|
||||||
}
|
}
|
||||||
@@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type AttachVnicInput struct {
|
|||||||
AssignPublicIP bool `json:"assignPublicIp"`
|
AssignPublicIP bool `json:"assignPublicIp"`
|
||||||
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
|
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
|
||||||
AssignIpv6 bool `json:"assignIpv6"`
|
AssignIpv6 bool `json:"assignIpv6"`
|
||||||
|
// ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。
|
||||||
|
ReservedPublicIPID string `json:"reservedPublicIpId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
|
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
|
||||||
@@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Vnic{}, err
|
return Vnic{}, err
|
||||||
}
|
}
|
||||||
|
// 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次
|
||||||
|
if in.ReservedPublicIPID != "" {
|
||||||
|
in.AssignPublicIP = false
|
||||||
|
}
|
||||||
details := &core.CreateVnicDetails{
|
details := &core.CreateVnicDetails{
|
||||||
SubnetId: &in.SubnetID,
|
SubnetId: &in.SubnetID,
|
||||||
AssignPublicIp: &in.AssignPublicIP,
|
AssignPublicIp: &in.AssignPublicIP,
|
||||||
|
|||||||
+327
-31
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -61,20 +62,82 @@ type AiGatewayService struct {
|
|||||||
onChannelsChanged func(context.Context)
|
onChannelsChanged func(context.Context)
|
||||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||||
filterDeprecated atomic.Bool
|
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 后开始调用日志周期清理。
|
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||||
var row model.Setting
|
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
|
||||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
|
||||||
s.filterDeprecated.Store(row.Value == "1")
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
|
||||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
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 返回「过滤弃用模型」开关状态。
|
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||||
@@ -82,18 +145,73 @@ func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.L
|
|||||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||||
value := "0"
|
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
|
||||||
if on {
|
|
||||||
value = "1"
|
|
||||||
}
|
|
||||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||||
}
|
}
|
||||||
s.filterDeprecated.Store(on)
|
s.filterDeprecated.Store(on)
|
||||||
return nil
|
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 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||||
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
||||||
s.onChannelsChanged = fn
|
s.onChannelsChanged = fn
|
||||||
@@ -268,11 +386,34 @@ func valueOr(p *int, def int) int {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels 列出全部渠道。
|
// Channels 列出全部渠道并回填各自的模型缓存计数。
|
||||||
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
||||||
var chs []model.AiChannel
|
var chs []model.AiChannel
|
||||||
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
|
if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil {
|
||||||
return chs, err
|
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 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
|
// 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) {
|
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
|
||||||
var ch model.AiChannel
|
var ch model.AiChannel
|
||||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
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)
|
return s.probeChat(ctx, cred, ch, models)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
|
// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需
|
||||||
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
|
// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入
|
||||||
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
|
// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选
|
||||||
|
// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。
|
||||||
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||||
status, detail := "error", "无可试调对话模型"
|
status, detail := "error", "无可试调对话模型"
|
||||||
|
quotaDetail := ""
|
||||||
errBudget := 3
|
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)
|
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
||||||
switch {
|
switch {
|
||||||
case code == 200 || code == 429:
|
case code == 200 || code == 429:
|
||||||
@@ -378,7 +521,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
|||||||
case oci.IsModelUnavailable(err):
|
case oci.IsModelUnavailable(err):
|
||||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||||
case code == 401 || code == 403 || code == 404:
|
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:
|
default:
|
||||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||||
if errBudget--; errBudget == 0 {
|
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
|
return status, detail
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,13 +539,18 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
|||||||
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||||
const probeCandidateCap = 8
|
const probeCandidateCap = 8
|
||||||
|
|
||||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
|
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的
|
||||||
|
// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先;
|
||||||
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
||||||
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
||||||
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
||||||
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel {
|
||||||
var sorted []oci.GenAiModel
|
var pinned, sorted []oci.GenAiModel
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
if probeModel != "" && m.Name == probeModel {
|
||||||
|
pinned = append(pinned, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
||||||
sorted = append(sorted, m)
|
sorted = append(sorted, m)
|
||||||
}
|
}
|
||||||
@@ -407,7 +558,7 @@ func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
|||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
||||||
})
|
})
|
||||||
return diversifyByVendor(sorted, probeCandidateCap)
|
return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
|
// 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) {
|
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
|
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
|
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;
|
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
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
|
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 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
||||||
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
||||||
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
||||||
@@ -795,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
on, err := contentLogStillOn(tx, entry.KeyID)
|
||||||
|
if err != nil || !on {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Create(&entry).Error
|
return tx.Create(&entry).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
func truncateBody(s string) string {
|
||||||
if len(s) > aiContentBodyLimit {
|
if len(s) > aiContentBodyLimit {
|
||||||
return 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")
|
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) {
|
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
|
||||||
cutoff := time.Now().Add(-retention)
|
cutoff := time.Now().Add(-retention)
|
||||||
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
|
||||||
@@ -866,15 +1142,35 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
|
|||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
|
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
|
||||||
|
log.Printf("%s cleanup count: %v", tag, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if overflow := int(total) - maxRows; overflow > 0 {
|
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
|
var ids []uint
|
||||||
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
|
if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
|
||||||
if len(ids) > 0 {
|
return 0, fmt.Errorf("pluck oldest: %w", err)
|
||||||
s.db.WithContext(ctx).Delete(m, ids)
|
|
||||||
}
|
}
|
||||||
|
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 退出。
|
// Wait 等待后台清理 goroutine 退出。
|
||||||
|
|||||||
@@ -27,27 +27,27 @@ type aiCandidate struct {
|
|||||||
modelOcid string
|
modelOcid string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 →
|
// routeRetry 统一编排「选渠道(priority→加权随机)→ 调用 → 可重试错误换渠道」,
|
||||||
// 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。
|
// 整请求上限 3 次并维护熔断;group 非空时只在同分组渠道内路由。
|
||||||
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
|
func routeRetry[T any](ctx context.Context, s *AiGatewayService, modelName, group, capability string, once func(*aiCandidate) (T, error)) (T, ChatMeta, error) {
|
||||||
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
|
var zero T
|
||||||
meta := ChatMeta{}
|
meta := ChatMeta{}
|
||||||
excluded := map[uint]bool{}
|
excluded := map[uint]bool{}
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
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 {
|
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
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
payload, err := s.passthroughOnce(ctx, cand, raw)
|
out, err := once(cand)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
return payload, meta, nil
|
return out, meta, nil
|
||||||
}
|
}
|
||||||
retry, penalize := switchable(err)
|
retry, penalize := switchable(err)
|
||||||
if !retry {
|
if !retry {
|
||||||
return nil, meta, err
|
return zero, meta, err
|
||||||
}
|
}
|
||||||
if penalize {
|
if penalize {
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
s.markFailure(ctx, cand.ch.ID)
|
||||||
@@ -56,7 +56,15 @@ func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, mode
|
|||||||
meta.Retries++
|
meta.Retries++
|
||||||
lastErr = err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
|
||||||
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。
|
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。
|
||||||
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
|
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
return routeRetry(ctx, s, modelName, group, "CHAT", func(cand *aiCandidate) (io.ReadCloser, error) {
|
||||||
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
|
|
||||||
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw)
|
return s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
|
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
|
||||||
@@ -221,34 +206,11 @@ func weightedPick(chs []model.AiChannel) model.AiChannel {
|
|||||||
return chs[len(chs)-1]
|
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) {
|
func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
return routeRetry(ctx, s, req.Model, group, "EMBEDDING", func(cand *aiCandidate) (*aiwire.EmbeddingsResponse, error) {
|
||||||
excluded := map[uint]bool{}
|
return s.embedOnce(ctx, cand, req)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
|
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"oci-portal/internal/aiwire"
|
"oci-portal/internal/aiwire"
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||||
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
|
|||||||
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
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, "密钥已删后不得写入")
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.C
|
|||||||
return f.guardOutcome, f.guardErr
|
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.passCalls++
|
||||||
f.passRegions = append(f.passRegions, region)
|
f.passRegions = append(f.passRegions, region)
|
||||||
if len(f.passErrs) > 0 {
|
if len(f.passErrs) > 0 {
|
||||||
@@ -96,7 +96,7 @@ func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.C
|
|||||||
return f.passPayload, nil
|
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.passCalls++
|
||||||
f.passRegions = append(f.passRegions, region)
|
f.passRegions = append(f.passRegions, region)
|
||||||
if len(f.passErrs) > 0 {
|
if len(f.passErrs) > 0 {
|
||||||
@@ -453,7 +453,7 @@ func TestProbeCandidates(t *testing.T) {
|
|||||||
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
||||||
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
|
{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" {
|
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)
|
t.Errorf("候选排序 = %+v", got)
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,7 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
|
|||||||
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
|
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
|
||||||
{Ocid: "g1", Name: "xai.grok-4"},
|
{Ocid: "g1", Name: "xai.grok-4"},
|
||||||
}
|
}
|
||||||
got := probeCandidates(models)
|
got := probeCandidates("", models)
|
||||||
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
|
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
|
||||||
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
|
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
|
||||||
}
|
}
|
||||||
@@ -489,11 +489,38 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
|
|||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", 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))
|
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(模型在区域内无按需供给)。
|
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
|
||||||
func entityNotFoundErr() stubServiceError {
|
func entityNotFoundErr() stubServiceError {
|
||||||
return stubServiceError{status: 404,
|
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) {
|
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
|
||||||
// 微调基座 400 换渠道重试成功,且不计入熔断失败
|
// 微调基座 400 换渠道重试成功,且不计入熔断失败
|
||||||
client := &gatewayStubClient{
|
client := &gatewayStubClient{
|
||||||
@@ -911,3 +1060,102 @@ func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
|||||||
t.Errorf("流内容未透传: %s", payload)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ func respRejectStateful(req aiwire.RespRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||||
// 只放行 function 与 Oracle 文档化的服务端工具(web_search / x_search /
|
// 放行 function、Oracle 文档化的服务端工具(web_search / x_search /
|
||||||
// code_interpreter / mcp)。
|
// code_interpreter / mcp)与 codex 特有形态(namespace 工具分组、custom 自由
|
||||||
|
// 格式,上游不识别,转发前拍平/转换)。
|
||||||
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||||
if strings.TrimSpace(req.Model) == "" {
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
return fmt.Errorf("model 不能为空")
|
return fmt.Errorf("model 不能为空")
|
||||||
@@ -34,7 +35,7 @@ func RespPassthroughValidate(req aiwire.RespRequest) error {
|
|||||||
}
|
}
|
||||||
for _, t := range req.Tools {
|
for _, t := range req.Tools {
|
||||||
switch t.Type {
|
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:
|
default:
|
||||||
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
|
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
|
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(禁上游
|
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
|
||||||
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
|
// 存态),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 := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
dec.UseNumber()
|
dec.UseNumber()
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
if err := dec.Decode(&body); err != nil {
|
if err := dec.Decode(&body); err != nil {
|
||||||
return nil, fmt.Errorf("解析请求体: %w", err)
|
return nil, RespCompat{}, fmt.Errorf("解析请求体: %w", err)
|
||||||
}
|
}
|
||||||
body["store"] = false
|
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(日志记零)。
|
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -43,6 +45,9 @@ func TestRespPassthroughValidate(t *testing.T) {
|
|||||||
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||||
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, 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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -56,10 +61,13 @@ func TestRespPassthroughValidate(t *testing.T) {
|
|||||||
|
|
||||||
func TestRespPassthroughBody(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}}`)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RespPassthroughBody: %v", err)
|
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
|
var body map[string]any
|
||||||
if err := json.Unmarshal(out, &body); err != nil {
|
if err := json.Unmarshal(out, &body); err != nil {
|
||||||
t.Fatalf("unmarshal out: %v", err)
|
t.Fatalf("unmarshal out: %v", err)
|
||||||
@@ -76,11 +84,206 @@ func TestRespPassthroughBody(t *testing.T) {
|
|||||||
if !strings.Contains(string(out), `"custom_field"`) {
|
if !strings.Contains(string(out), `"custom_field"`) {
|
||||||
t.Errorf("未知字段应保留: %s", out)
|
t.Errorf("未知字段应保留: %s", out)
|
||||||
}
|
}
|
||||||
if _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
if _, _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||||
t.Error("非法 JSON 应报错")
|
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) {
|
func TestRespPassthroughUsage(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import (
|
|||||||
"oci-portal/internal/oci"
|
"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,返回新地址。
|
// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。
|
||||||
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
|
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
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)
|
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) {
|
func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
|
||||||
if instanceID == "" || in.SubnetID == "" {
|
if instanceID == "" || in.SubnetID == "" {
|
||||||
return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required")
|
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 {
|
if err != nil {
|
||||||
return oci.Vnic{}, err
|
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 拒绝)。
|
// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。
|
||||||
|
|||||||
@@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to
|
|||||||
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
||||||
|
|
||||||
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
||||||
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。
|
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数;
|
||||||
|
// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。
|
||||||
type AuditQuery struct {
|
type AuditQuery struct {
|
||||||
Region string
|
Region string
|
||||||
Cursor string
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
|
Q string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
||||||
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。
|
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头;
|
||||||
|
// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。
|
||||||
type AuditEventsView struct {
|
type AuditEventsView struct {
|
||||||
Items []oci.AuditEvent `json:"items"`
|
Items []oci.AuditEvent `json:"items"`
|
||||||
Cursor string `json:"cursor,omitempty"`
|
Cursor string `json:"cursor,omitempty"`
|
||||||
Exhausted bool `json:"exhausted"`
|
Exhausted bool `json:"exhausted"`
|
||||||
|
ScannedThrough *time.Time `json:"scannedThrough,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
||||||
@@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
}
|
}
|
||||||
|
if q.Cursor == "" {
|
||||||
|
cur.Q = oci.SanitizeAuditTerm(q.Q)
|
||||||
|
}
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
@@ -63,6 +70,7 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
||||||
if res.Cursor != nil {
|
if res.Cursor != nil {
|
||||||
view.Cursor = encodeAuditCursor(*res.Cursor)
|
view.Cursor = encodeAuditCursor(*res.Cursor)
|
||||||
|
view.ScannedThrough = &res.Cursor.End
|
||||||
}
|
}
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-31
@@ -39,13 +39,19 @@ type authClaims struct {
|
|||||||
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
||||||
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
||||||
|
|
||||||
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)和 JWT 签发与验证。
|
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)、JWT 签发与验证,
|
||||||
|
// 以及会话落地与管理(session.go)。
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
guard *loginGuard
|
guard *loginGuard
|
||||||
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
||||||
revoked *cache.Cache
|
revoked *cache.Cache
|
||||||
|
// seen 是会话最后活跃回写的节流缓存(jti→占位);撤销时清键保证即时生效
|
||||||
|
seen *cache.Cache
|
||||||
|
// revokedJti 是定点撤销的负缓存:无容量上限的专用结构,
|
||||||
|
// 既不与高频 seen 回写争容量,也不存在满载淘汰导致的令牌复活
|
||||||
|
revokedJti *jtiTombstones
|
||||||
|
|
||||||
notifier *Notifier
|
notifier *Notifier
|
||||||
settings *SettingService
|
settings *SettingService
|
||||||
@@ -53,6 +59,9 @@ type AuthService struct {
|
|||||||
|
|
||||||
totpMu sync.Mutex
|
totpMu sync.Mutex
|
||||||
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
||||||
|
|
||||||
|
// cleanupWG 追踪会话清理 goroutine;关停时 Wait 保证清理查询已结束
|
||||||
|
cleanupWG sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService 组装依赖。
|
// NewAuthService 组装依赖。
|
||||||
@@ -61,6 +70,8 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
|||||||
db: db, jwtSecret: []byte(jwtSecret),
|
db: db, jwtSecret: []byte(jwtSecret),
|
||||||
guard: newLoginGuard(),
|
guard: newLoginGuard(),
|
||||||
revoked: cache.New(4096),
|
revoked: cache.New(4096),
|
||||||
|
seen: cache.New(4096),
|
||||||
|
revokedJti: newJtiTombstones(),
|
||||||
totpPending: map[string]pendingTotp{},
|
totpPending: map[string]pendingTotp{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,11 +120,12 @@ func (s *AuthService) createUser(username, password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破,
|
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT 并落地会话;
|
||||||
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
|
// 按「IP+用户名」滑动窗口防爆破,锁定期内一律 ErrLoginLocked(正确密码同样拒绝);
|
||||||
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
|
// 阈值与时长取安全设置。已启用两步验证时:密码通过但缺验证码返回
|
||||||
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
|
// ErrTotpRequired(不计失败),验证码错误计入守卫。
|
||||||
key := guardKey(clientIP, username)
|
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()
|
now := time.Now()
|
||||||
sec := securityOf(s.settings)
|
sec := securityOf(s.settings)
|
||||||
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// 与密码错误分支对齐耗时,防用户枚举
|
// 与密码错误分支对齐耗时,防用户枚举
|
||||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
_ = 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)
|
return "", time.Time{}, fmt.Errorf("find user: %w", err)
|
||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
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 user.TotpSecretEnc != "" {
|
||||||
if totpCode == "" {
|
if totpCode == "" {
|
||||||
return "", time.Time{}, ErrTotpRequired
|
return "", time.Time{}, ErrTotpRequired
|
||||||
}
|
}
|
||||||
if !s.verifyTotp(&user, totpCode) {
|
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)
|
s.guard.success(key)
|
||||||
return s.signToken(user.Username, user.TokenVersion)
|
meta.Method = "password"
|
||||||
|
return s.signSessionToken(ctx, &user, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
|
// 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()
|
now := time.Now()
|
||||||
expires := now.Add(tokenTTL)
|
expires := now.Add(tokenTTL)
|
||||||
claims := authClaims{
|
claims := authClaims{
|
||||||
@@ -184,31 +203,37 @@ func (s *AuthService) signToken(username string, ver uint) (string, time.Time, e
|
|||||||
Subject: username,
|
Subject: username,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(expires),
|
ExpiresAt: jwt.NewNumericDate(expires),
|
||||||
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
|
ID: jti,
|
||||||
ID: newTokenID(),
|
|
||||||
},
|
},
|
||||||
Ver: ver,
|
Ver: ver,
|
||||||
}
|
}
|
||||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||||
if err != nil {
|
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) {
|
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
|
||||||
user, err := s.findUser(ctx, username)
|
user, err := s.findUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", time.Time{}, err
|
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 原子递增账号令牌版本,使所有已签发令牌立即失效。
|
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
|
||||||
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
|
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
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bump token version: %w", err)
|
return fmt.Errorf("bump token version: %w", err)
|
||||||
@@ -216,12 +241,28 @@ func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌。
|
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌;
|
||||||
func (s *AuthService) RevokeSessions(ctx context.Context, username string) (string, time.Time, error) {
|
// oldToken 非空时其会话行接续到新令牌(当前设备在列表中保持连续)。
|
||||||
if err := s.bumpTokenVersion(ctx, username); err != nil {
|
// 行锁下复核 proof:请求挂起期间令牌已失效则拒绝,并发敏感操作后到者拒。
|
||||||
return "", time.Time{}, err
|
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
|
||||||
}
|
}
|
||||||
return s.IssueToken(ctx, username)
|
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 起不会失败)。
|
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
|
||||||
@@ -231,9 +272,28 @@ func newTokenID() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenProof 是鉴权时观察到的令牌快照(版本 + jti):敏感事务在用户行锁下
|
||||||
|
// 复核它仍是账号当前状态,防「请求挂起期间用户撤销 / 注销,恢复后仍完成
|
||||||
|
// 敏感变更并换出新令牌」的在途绕过。
|
||||||
|
type TokenProof struct {
|
||||||
|
Ver uint
|
||||||
|
Jti string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTokenStale 表示请求令牌在处理期间已失效(版本已递增或会话已撤销);
|
||||||
|
// api 层映射 401,前端按「发送时令牌 == 当前令牌」决定是否登出。
|
||||||
|
var ErrTokenStale = errors.New("会话已失效,请重新登录")
|
||||||
|
|
||||||
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
||||||
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
||||||
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
|
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{}
|
claims := &authClaims{}
|
||||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
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
|
return s.jwtSecret, nil
|
||||||
})
|
})
|
||||||
if err != 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 {
|
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)
|
user, err := s.findUser(ctx, claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("token subject: %w", err)
|
return "", TokenProof{}, fmt.Errorf("token subject: %w", err)
|
||||||
}
|
}
|
||||||
if claims.Ver != user.TokenVersion {
|
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 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
|
// Logout 把令牌拉进黑名单直至其自然过期,并标记对应会话行已撤销;
|
||||||
func (s *AuthService) Logout(tokenString string) {
|
// 无效/已过期令牌直接视为成功(幂等)。
|
||||||
|
func (s *AuthService) Logout(ctx context.Context, tokenString string) {
|
||||||
claims := &jwt.RegisteredClaims{}
|
claims := &jwt.RegisteredClaims{}
|
||||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
return s.jwtSecret, nil
|
return s.jwtSecret, nil
|
||||||
@@ -271,6 +335,7 @@ func (s *AuthService) Logout(tokenString string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
|
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
|
||||||
|
s.revokeSessionByJTI(ctx, claims.Subject, claims.ID, ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func newTestAuth(t *testing.T) *AuthService {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open in-memory sqlite: %v", err)
|
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)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewAuthService(db, "test-jwt-secret")
|
return NewAuthService(db, "test-jwt-secret")
|
||||||
@@ -32,7 +32,7 @@ func TestEnsureAdminCreatesUser(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
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)
|
t.Errorf("Login after EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ func TestEnsureAdminDoesNotResetPassword(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "second"); err != nil {
|
if err := auth.EnsureAdmin("admin", "second"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin twice: %v", err)
|
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)
|
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)
|
t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestLoginFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
if !errors.Is(err, ErrInvalidCredentials) {
|
||||||
t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err)
|
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 {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func TestParseTokenRejectsForged(t *testing.T) {
|
|||||||
if err := other.EnsureAdmin("admin", "pass"); err != nil {
|
if err := other.EnsureAdmin("admin", "pass"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -143,21 +143,21 @@ func TestLogoutRevokesToken(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
token, _, err := auth.signToken("admin", 0)
|
token, _, _, err := auth.signToken("admin", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signToken: %v", err)
|
t.Fatalf("signToken: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
||||||
t.Fatalf("ParseToken before logout: %v", err)
|
t.Fatalf("ParseToken before logout: %v", err)
|
||||||
}
|
}
|
||||||
auth.Logout(token)
|
auth.Logout(context.Background(), token)
|
||||||
if _, err := auth.ParseToken(context.Background(), token); err == nil {
|
if _, err := auth.ParseToken(context.Background(), token); err == nil {
|
||||||
t.Error("ParseToken after logout: got nil error, want revoked")
|
t.Error("ParseToken after logout: got nil error, want revoked")
|
||||||
}
|
}
|
||||||
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
||||||
auth.Logout(token)
|
auth.Logout(context.Background(), token)
|
||||||
auth.Logout("not.a.token")
|
auth.Logout(context.Background(), "not.a.token")
|
||||||
fresh, _, err := auth.signToken("admin", 0)
|
fresh, _, _, err := auth.signToken("admin", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signToken fresh: %v", err)
|
t.Fatalf("signToken fresh: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,13 +174,13 @@ func TestTokenVersionInvalidatesOldToken(t *testing.T) {
|
|||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
||||||
NewPassword: "changed-456", CurrentPassword: "pass123",
|
NewPassword: "changed-456", CurrentPassword: "pass123",
|
||||||
})
|
}, proofOf(t, auth.db, "admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateCredentials: %v", err)
|
t.Fatalf("UpdateCredentials: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,11 +203,11 @@ func TestRevokeSessions(t *testing.T) {
|
|||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RevokeSessions: %v", err)
|
t.Fatalf("RevokeSessions: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user