Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0614ef22af | ||
|
|
33e92a65e2 | ||
|
|
f40f2a20e8 | ||
|
|
9cfde8b702 | ||
|
|
deea8629e0 | ||
|
|
6cf9465fea | ||
|
|
882eeade1e | ||
|
|
7019d4c5a6 | ||
|
|
18e63d2dbd | ||
|
|
91999205e2 | ||
|
|
cb66567256 |
@@ -27,3 +27,34 @@ 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。
|
||||||
|
- 自建代理 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 退避),并发提速与限流保护成对出现,二者缺一不可。
|
||||||
|
|||||||
@@ -24,7 +24,20 @@ Questions to answer:
|
|||||||
|
|
||||||
<!-- How should queries be written? Batch operations? -->
|
<!-- How should queries be written? Batch operations? -->
|
||||||
|
|
||||||
(To be filled by the team)
|
### 租户级数据删除
|
||||||
|
|
||||||
|
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
||||||
|
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
||||||
|
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
||||||
|
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
||||||
|
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
||||||
|
|
||||||
|
### 大集合谓词用子查询,禁止展开 IN 列表
|
||||||
|
|
||||||
|
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
|
||||||
|
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
|
||||||
|
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
||||||
|
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,6 +61,14 @@ Questions to answer:
|
|||||||
|
|
||||||
<!-- Database-related mistakes your team has made -->
|
<!-- Database-related mistakes your team has made -->
|
||||||
|
|
||||||
|
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
|
||||||
|
|
||||||
|
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
|
||||||
|
|
||||||
|
**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
|
||||||
|
|
||||||
|
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。
|
||||||
|
|
||||||
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||||
|
|
||||||
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
||||||
@@ -66,3 +87,19 @@ db.Model(&model.AiChannel{}).
|
|||||||
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
|
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 用例。
|
||||||
|
|||||||
@@ -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,13 @@ 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` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
|
||||||
|
|||||||
@@ -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,7 +19,7 @@ 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 模式、查询、迁移 | 待填 |
|
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 部分已填 |
|
||||||
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,14 @@
|
|||||||
- 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(去空白),字段顺序与内容保持原样,符合保真要求。
|
||||||
|
|||||||
@@ -2,6 +2,53 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [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]
|
## [0.7.1]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.7.1
|
v0.8.0
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
|
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)。
|
||||||
|
|
||||||
@@ -48,11 +48,13 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
| 能力域 | 覆盖范围 |
|
| 能力域 | 覆盖范围 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 |
|
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象、在线预览编辑、PAR 直传分享)与限额查询 |
|
||||||
|
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
|
||||||
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
||||||
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
||||||
|
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*`、`/ai/*` 实时请求 |
|
||||||
|
|
||||||
## 运行形态
|
## 运行形态
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+26
-1
@@ -196,7 +196,9 @@ Codex 工具兼容现状:
|
|||||||
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
||||||
|
|
||||||
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
||||||
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的
|
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。2026-07-21
|
||||||
|
再次复测(Chicago 签名路径)仍未修复:`70,463 B` 两次均在数个推理 delta 后纯
|
||||||
|
EOF,同时段 `59,651 B` 对照正常 `completed`。本文及设置页中的
|
||||||
`KB` 均按 `1024 B` 计算。
|
`KB` 均按 `1024 B` 计算。
|
||||||
|
|
||||||
| 协议 | 网关保护 | 客户端表现 |
|
| 协议 | 网关保护 | 客户端表现 |
|
||||||
@@ -218,6 +220,29 @@ Responses 合成的最小事件序列为:`response.created` →
|
|||||||
|
|
||||||
</details>
|
</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 与文件输入
|
### ZDR 与文件输入
|
||||||
|
|
||||||
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
|
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
|
||||||
|
|||||||
+1555
-2
File diff suppressed because it is too large
Load Diff
+1556
-3
File diff suppressed because it is too large
Load Diff
+1004
-3
File diff suppressed because it is too large
Load Diff
+19
-6
@@ -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"
|
||||||
|
|
||||||
@@ -450,6 +451,9 @@ type aiSettingsResponse struct {
|
|||||||
// 请求 tools 已包含同名工具时不覆盖
|
// 请求 tools 已包含同名工具时不覆盖
|
||||||
GrokWebSearch bool `json:"grokWebSearch"`
|
GrokWebSearch bool `json:"grokWebSearch"`
|
||||||
GrokXSearch bool `json:"grokXSearch"`
|
GrokXSearch bool `json:"grokXSearch"`
|
||||||
|
// UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次
|
||||||
|
// 尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s
|
||||||
|
UpstreamWaitSeconds int `json:"upstreamWaitSeconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentAiSettings 汇总网关运行时设置为响应体。
|
// currentAiSettings 汇总网关运行时设置为响应体。
|
||||||
@@ -457,11 +461,12 @@ func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse {
|
|||||||
guardOn, guardKB := h.gw.StreamGuard()
|
guardOn, guardKB := h.gw.StreamGuard()
|
||||||
web, x := h.gw.GrokSearch()
|
web, x := h.gw.GrokSearch()
|
||||||
return aiSettingsResponse{
|
return aiSettingsResponse{
|
||||||
FilterDeprecated: h.gw.FilterDeprecated(),
|
FilterDeprecated: h.gw.FilterDeprecated(),
|
||||||
StreamGuardEnabled: guardOn,
|
StreamGuardEnabled: guardOn,
|
||||||
StreamGuardKB: guardKB,
|
StreamGuardKB: guardKB,
|
||||||
GrokWebSearch: web,
|
GrokWebSearch: web,
|
||||||
GrokXSearch: x,
|
GrokXSearch: x,
|
||||||
|
UpstreamWaitSeconds: int(h.gw.UpstreamWait() / time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +485,7 @@ func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 更新 AI 网关全局设置
|
// @Summary 更新 AI 网关全局设置
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB"
|
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒"
|
||||||
// @Success 200 {object} aiSettingsResponse
|
// @Success 200 {object} aiSettingsResponse
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -495,6 +500,10 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.UpstreamWaitSeconds < 30 || req.UpstreamWaitSeconds > 900 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "upstreamWaitSeconds 须在 30..900"})
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
|
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
@@ -508,5 +517,9 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.gw.SetUpstreamWait(ctx, req.UpstreamWaitSeconds); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, h.currentAiSettings())
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -44,6 +44,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 +119,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,
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 对象存储 ----
|
||||||
|
|
||||||
|
// @Summary 对象存储 namespace
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/object-storage/namespace [get]
|
||||||
|
func (h *ociConfigHandler) osNamespace(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ns, err := h.svc.ObjectStorageNamespace(c.Request.Context(), id, c.Query("region"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"namespace": ns})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 存储桶列表
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param compartmentId query string false "区间 OCID,空为生效区间"
|
||||||
|
// @Success 200 {array} oci.Bucket
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets [get]
|
||||||
|
func (h *ociConfigHandler) listBuckets(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buckets, err := h.svc.Buckets(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, buckets)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createBucketRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
CompartmentID string `json:"compartmentId"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
PublicRead bool `json:"publicRead"`
|
||||||
|
StorageTier string `json:"storageTier"` // Standard / Archive
|
||||||
|
VersioningOn bool `json:"versioningOn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 创建存储桶
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param body body createBucketRequest true "请求体"
|
||||||
|
// @Success 201 {object} oci.Bucket
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets [post]
|
||||||
|
func (h *ociConfigHandler) createBucket(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createBucketRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket, err := h.svc.CreateBucket(c.Request.Context(), id, req.Region, oci.CreateBucketInput{
|
||||||
|
Name: req.Name, CompartmentID: req.CompartmentID,
|
||||||
|
PublicRead: req.PublicRead, StorageTier: req.StorageTier, VersioningOn: req.VersioningOn,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateBucketRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
PublicRead *bool `json:"publicRead"`
|
||||||
|
VersioningOn *bool `json:"versioningOn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 更新存储桶(可见性/版本控制)
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param body body updateBucketRequest true "请求体"
|
||||||
|
// @Success 200 {object} oci.Bucket
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [put]
|
||||||
|
func (h *ociConfigHandler) updateBucket(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateBucketRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket, err := h.svc.UpdateBucket(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.UpdateBucketInput{
|
||||||
|
PublicRead: req.PublicRead, VersioningOn: req.VersioningOn,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除存储桶(非空桶后台清空后删除)
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Success 200 {object} map[string]bool "queued=true 表示已转后台清空删除"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [delete]
|
||||||
|
func (h *ociConfigHandler) deleteBucket(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queued, err := h.svc.DeleteBucket(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"queued": queued})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 对象列表(前缀模式分页)
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param prefix query string false "前缀(虚拟目录)"
|
||||||
|
// @Param startWith query string false "上页 nextStartWith 游标"
|
||||||
|
// @Param limit query int false "每页数量,默认 100"
|
||||||
|
// @Success 200 {object} oci.ListObjectsResult
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [get]
|
||||||
|
func (h *ociConfigHandler) listObjects(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
|
result, err := h.svc.Objects(c.Request.Context(), id, c.Query("region"),
|
||||||
|
c.Param("bucket"), c.Query("prefix"), c.Query("startWith"), limit)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除对象
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param object query string true "对象名(完整 key)"
|
||||||
|
// @Success 204 "删除成功"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [delete]
|
||||||
|
func (h *ociConfigHandler) deleteObject(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.DeleteObject(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 重命名对象
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param body body object true "请求体:region、source、newName"
|
||||||
|
// @Success 204 "重命名成功"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename [post]
|
||||||
|
func (h *ociConfigHandler) renameObject(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Source string `json:"source" binding:"required"`
|
||||||
|
NewName string `json:"newName" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.RenameObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Source, req.NewName)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 取回 Archive 对象
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param body body object true "请求体:region、object、hours(可下载时长,默认 24)"
|
||||||
|
// @Success 202 "取回已提交,约 1 小时后可下载"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore [post]
|
||||||
|
func (h *ociConfigHandler) restoreObject(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Object string `json:"object" binding:"required"`
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.RestoreObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Object, req.Hours)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 对象元数据
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param object query string true "对象名(完整 key)"
|
||||||
|
// @Success 200 {object} oci.ObjectDetail
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail [get]
|
||||||
|
func (h *ociConfigHandler) objectDetail(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.svc.ObjectDetail(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPutContentBody 是保存对象内容的请求体上限(与 service 层 5MB 限制一致)。
|
||||||
|
const maxPutContentBody = 5 << 20
|
||||||
|
|
||||||
|
// respondContentError 对象内容中转专属错误:超限映射 413,其余走通用处理。
|
||||||
|
func respondContentError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, oci.ErrObjectTooLarge) {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "对象超出面板中转大小上限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 读取对象内容(面板中转)
|
||||||
|
// @Description 预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param object query string true "对象名(完整 key)"
|
||||||
|
// @Success 200 {string} string "对象原始内容"
|
||||||
|
// @Failure 413 {object} map[string]string "对象超出中转上限"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [get]
|
||||||
|
func (h *ociConfigHandler) getObjectContent(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := h.svc.ObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
|
||||||
|
if err != nil {
|
||||||
|
respondContentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := content.ContentType
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
c.Header("ETag", content.Etag)
|
||||||
|
c.Data(http.StatusOK, ct, content.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 保存对象内容(面板中转)
|
||||||
|
// @Description 文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param object query string true "对象名(完整 key)"
|
||||||
|
// @Success 200 {object} map[string]string "etag=新 ETag"
|
||||||
|
// @Failure 412 {object} map[string]string "If-Match 不匹配,对象已被并发修改"
|
||||||
|
// @Failure 413 {object} map[string]string "请求体超出上限"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [put]
|
||||||
|
func (h *ociConfigHandler) putObjectContent(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxPutContentBody)
|
||||||
|
data, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "请求体超出 5MB 上限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
etag, err := h.svc.PutObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"),
|
||||||
|
c.Query("object"), data, c.ContentType(), c.GetHeader("If-Match"))
|
||||||
|
if err != nil {
|
||||||
|
respondContentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"etag": etag})
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPARRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ObjectName string `json:"objectName"` // 空 = 桶级
|
||||||
|
AccessType string `json:"accessType" binding:"required"`
|
||||||
|
ExpiresHours int `json:"expiresHours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 签发临时链接(PAR)
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param body body createPARRequest true "请求体"
|
||||||
|
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
|
||||||
|
func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createPARRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
par, err := h.svc.CreatePAR(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.CreatePARInput{
|
||||||
|
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, par)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
|
||||||
|
type parPage struct {
|
||||||
|
Items []oci.PAR `json:"items"`
|
||||||
|
NextPage string `json:"nextPage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 临时链接列表
|
||||||
|
// @Description 游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Param page query string false "分页游标(上次响应的 nextPage,空取首页)"
|
||||||
|
// @Param limit query int false "每页条数,默认 100,上限 1000"
|
||||||
|
// @Success 200 {object} parPage
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [get]
|
||||||
|
func (h *ociConfigHandler) listPARs(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
|
items, next, err := h.svc.PARsPage(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("page"), limit)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, parPage{Items: items, NextPage: next})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除临时链接
|
||||||
|
// @Description all=1 时删除桶内全部 PAR 并返回 {"deleted": n};否则按 parId 删单条
|
||||||
|
// @Tags 对象存储
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param bucket path string true "桶名"
|
||||||
|
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
|
||||||
|
// @Param all query string false "为 1 时删除全部"
|
||||||
|
// @Param region query string false "区域"
|
||||||
|
// @Success 204 "删除成功,链接立即失效"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
|
||||||
|
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Query("all") == "1" {
|
||||||
|
n, err := h.svc.DeleteAllPARs(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.DeletePAR(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("parId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,27 @@ 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.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
||||||
|
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
|
||||||
|
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
|
||||||
|
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
|
||||||
|
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
|
||||||
|
}
|
||||||
|
|
||||||
// registerOciCost 成本快照。
|
// 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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -229,6 +229,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+45
-6
@@ -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,34 @@ 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
|
||||||
|
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 +137,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 文档重排,返回按相关度排序的下标与得分。
|
||||||
@@ -182,8 +219,10 @@ type Client interface {
|
|||||||
|
|
||||||
// RealClient 基于官方 SDK 实现 Client。
|
// RealClient 基于官方 SDK 实现 Client。
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
|
|||||||
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,6 +34,27 @@ 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)
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
||||||
|
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
||||||
|
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
||||||
|
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
|
||||||
|
// wait 为整请求总超时:非流式上游要等全部生成完才回响应头,SDK 默认 60s 会掐断慢模型。
|
||||||
|
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := ic.BaseClient
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, wait)
|
||||||
|
request, err := newCompatResponsesRequest(ctx, cred, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
response, err := client.Call(ctx, request)
|
response, err := client.Call(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,10 +67,46 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
|
|||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelReadCloser 在流关闭时同步取消建立阶段派生的 ctx,避免其随流生命周期泄漏。
|
||||||
|
type cancelReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cancelReadCloser) Close() error {
|
||||||
|
c.cancel()
|
||||||
|
return c.ReadCloser.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpCaller 抽象 BaseClient.Call,便于对预算逻辑做无签名单测。
|
||||||
|
type httpCaller interface {
|
||||||
|
Call(ctx context.Context, request *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithHeaderBudget 以 wait 为等待响应头预算执行调用:预算内未返回则取消
|
||||||
|
// 请求(SDK Call 会把 ctx 重绑到请求);响应头到达即解除预算,之后流的生命
|
||||||
|
// 周期由 ctx 决定,返回的流 Close 时同步取消派生 ctx。
|
||||||
|
func callWithHeaderBudget(ctx context.Context, c httpCaller, req *http.Request, wait time.Duration) (io.ReadCloser, error) {
|
||||||
|
callCtx, cancel := context.WithCancel(ctx)
|
||||||
|
timer := time.AfterFunc(wait, cancel)
|
||||||
|
response, err := c.Call(callCtx, req)
|
||||||
|
timer.Stop()
|
||||||
|
if err != nil {
|
||||||
|
if response != nil && response.Body != nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cancelReadCloser{ReadCloser: response.Body, cancel: cancel}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
|
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
|
||||||
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
||||||
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
||||||
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
|
// 总超时置 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)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -55,19 +114,10 @@ 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, 0)
|
||||||
|
request, err := newCompatResponsesRequest(ctx, cred, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build compat responses stream request: %w", err)
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
|
||||||
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
|
||||||
response, err := client.Call(ctx, request)
|
|
||||||
if err != nil {
|
|
||||||
if response != nil && response.Body != nil {
|
|
||||||
response.Body.Close()
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return response.Body, nil
|
return callWithHeaderBudget(ctx, client, request, wait)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticDispatcher 是非 *http.Client 的自定义 dispatcher,用于降级分支。
|
||||||
|
type staticDispatcher struct{}
|
||||||
|
|
||||||
|
func (staticDispatcher) Do(*http.Request) (*http.Response, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestDispatcherWithTimeout(t *testing.T) {
|
||||||
|
tr := &http.Transport{}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in common.HTTPRequestDispatcher
|
||||||
|
timeout time.Duration
|
||||||
|
check func(t *testing.T, out common.HTTPRequestDispatcher)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http.Client 换总超时并保留 Transport", in: &http.Client{Transport: tr, Timeout: 60 * time.Second},
|
||||||
|
timeout: 300 * time.Second,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
hc, ok := out.(*http.Client)
|
||||||
|
if !ok || hc.Timeout != 300*time.Second || hc.Transport != tr {
|
||||||
|
t.Fatalf("期望拷贝 client 且 Timeout=300s、Transport 保留, 得到 %#v", out)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeout=0 表示无总超时", in: &http.Client{Timeout: 60 * time.Second}, timeout: 0,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
if hc := out.(*http.Client); hc.Timeout != 0 {
|
||||||
|
t.Fatalf("期望 Timeout=0, 得到 %v", hc.Timeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非 http.Client 原样返回", in: staticDispatcher{}, timeout: 300 * time.Second,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
if _, ok := out.(staticDispatcher); !ok {
|
||||||
|
t.Fatalf("期望原样返回自定义 dispatcher, 得到 %#v", out)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) { tt.check(t, dispatcherWithTimeout(tt.in, tt.timeout)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctxReader 模拟真实 HTTP body:请求 ctx 取消后读即失败。
|
||||||
|
type ctxReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ctxReader) Read(p []byte) (int, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return c.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeCaller 在 delay 后返回响应;期间 ctx 取消则按真实行为返回 ctx 错误。
|
||||||
|
type fakeCaller struct {
|
||||||
|
delay time.Duration
|
||||||
|
body string
|
||||||
|
gotCtx context.Context
|
||||||
|
failErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCaller) Call(ctx context.Context, _ *http.Request) (*http.Response, error) {
|
||||||
|
f.gotCtx = ctx
|
||||||
|
if f.failErr != nil {
|
||||||
|
return nil, f.failErr
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(f.delay):
|
||||||
|
body := &ctxReader{ctx: ctx, r: strings.NewReader(f.body)}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body)}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallWithHeaderBudget(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/actions/v1/responses", nil)
|
||||||
|
t.Run("预算内返回响应头后长读不受预算影响", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: 0, body: "data: hello"}
|
||||||
|
stream, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callWithHeaderBudget = %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
time.Sleep(90 * time.Millisecond) // 远超预算,验证响应头到达后预算已解除
|
||||||
|
payload, err := io.ReadAll(stream)
|
||||||
|
if err != nil || string(payload) != "data: hello" {
|
||||||
|
t.Fatalf("预算解除后读流 = %q, %v; 期望完整 body", payload, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("超预算未返回响应头即取消", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: time.Minute}
|
||||||
|
start := time.Now()
|
||||||
|
_, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("期望 context.Canceled, 得到 %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("取消耗时 %v, 未受预算约束", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("关闭流时取消派生 ctx", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: 0, body: "x"}
|
||||||
|
stream, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callWithHeaderBudget = %v", err)
|
||||||
|
}
|
||||||
|
stream.Close()
|
||||||
|
if fc.gotCtx.Err() == nil {
|
||||||
|
t.Fatal("Close 后派生 ctx 应已取消")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("建立失败时同样取消派生 ctx", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{failErr: errors.New("boom")}
|
||||||
|
if _, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute); err == nil {
|
||||||
|
t.Fatal("期望建立失败")
|
||||||
|
}
|
||||||
|
if fc.gotCtx.Err() == nil {
|
||||||
|
t.Fatal("失败路径派生 ctx 应已取消")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -234,8 +234,12 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
|
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||||
return res, err
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
|
||||||
}
|
}
|
||||||
details := sch.CreateServiceConnectorDetails{
|
details := sch.CreateServiceConnectorDetails{
|
||||||
DisplayName: common.String(relayConnectorName),
|
DisplayName: common.String(relayConnectorName),
|
||||||
@@ -257,6 +261,45 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
|
|||||||
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
|
||||||
|
// (如去除 LaunchInstance)后,已建链路经「一键创建」幂等调用原地更新,无需拆除重建。
|
||||||
|
func reconcileRelayCondition(ctx context.Context, sc sch.ServiceConnectorClient, id, condition string) error {
|
||||||
|
got, err := sc.GetServiceConnector(ctx, sch.GetServiceConnectorRequest{ServiceConnectorId: &id})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get service connector: %w", err)
|
||||||
|
}
|
||||||
|
if !relayConditionDiffers(got.Tasks, condition) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details := sch.UpdateServiceConnectorDetails{Tasks: []sch.TaskDetails{}}
|
||||||
|
if condition != "" {
|
||||||
|
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
|
||||||
|
}
|
||||||
|
_, err = sc.UpdateServiceConnector(ctx, sch.UpdateServiceConnectorRequest{
|
||||||
|
ServiceConnectorId: &id, UpdateServiceConnectorDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update service connector condition: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayConditionDiffers 判断现有任务集与期望条件是否不一致:
|
||||||
|
// 期望形态是单条 LogRule 条件;任务数、类型或条件文本不同均视为漂移。
|
||||||
|
func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
|
||||||
|
if want == "" {
|
||||||
|
return len(tasks) > 0
|
||||||
|
}
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rule, ok := tasks[0].(sch.LogRuleTaskDetailsResponse)
|
||||||
|
if !ok || rule.Condition == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *rule.Condition != want
|
||||||
|
}
|
||||||
|
|
||||||
// findRelayConnector 按名查找存活 Connector。
|
// findRelayConnector 按名查找存活 Connector。
|
||||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
||||||
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||||
|
|||||||
@@ -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,707 @@
|
|||||||
|
// 对象存储:namespace / 桶 / 对象 / 预签名请求(PAR)。
|
||||||
|
// 上传下载数据面走 PAR 直连 OCI,面板只做控制面;
|
||||||
|
// 例外:预览/编辑的小文件经面板中转(GetObject/PutObject),不签发 PAR。
|
||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/objectstorage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrObjectTooLarge 表示对象超出面板中转大小上限。
|
||||||
|
var ErrObjectTooLarge = errors.New("object too large for inline transfer")
|
||||||
|
|
||||||
|
// ObjectContent 是对象内容与写入所需元数据(面板中转小文件用)。
|
||||||
|
type ObjectContent struct {
|
||||||
|
Data []byte
|
||||||
|
ContentType string
|
||||||
|
Etag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket 是存储桶摘要(含用量估算,来自 GetBucket 的 approximate 字段)。
|
||||||
|
type Bucket struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Visibility string `json:"visibility"` // NoPublicAccess / ObjectRead
|
||||||
|
StorageTier string `json:"storageTier"`
|
||||||
|
VersioningOn bool `json:"versioningOn"`
|
||||||
|
ApproximateCount int64 `json:"approximateCount"`
|
||||||
|
ApproximateSize int64 `json:"approximateSize"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectSummary 是对象列表行;文件夹以 prefixes 单独返回。
|
||||||
|
type ObjectSummary struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
StorageTier string `json:"storageTier"`
|
||||||
|
ArchivalState string `json:"archivalState"`
|
||||||
|
TimeModified *time.Time `json:"timeModified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListObjectsResult 是分页对象列表:delimiter="/" 模式,子层级归入 Prefixes。
|
||||||
|
type ListObjectsResult struct {
|
||||||
|
Objects []ObjectSummary `json:"objects"`
|
||||||
|
Prefixes []string `json:"prefixes"`
|
||||||
|
NextStartWith string `json:"nextStartWith"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectDetail 是对象元数据(HEAD)。
|
||||||
|
type ObjectDetail struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Etag string `json:"etag"`
|
||||||
|
ContentMd5 string `json:"contentMd5"`
|
||||||
|
StorageTier string `json:"storageTier"`
|
||||||
|
ArchivalState string `json:"archivalState"`
|
||||||
|
TimeModified *time.Time `json:"timeModified"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAR 是预签名请求摘要;FullURL 仅创建响应携带(OCI 事后不可再取回)。
|
||||||
|
type PAR struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ObjectName string `json:"objectName"`
|
||||||
|
AccessType string `json:"accessType"`
|
||||||
|
FullURL string `json:"fullUrl,omitempty"`
|
||||||
|
TimeExpires *time.Time `json:"timeExpires"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBucketInput 创建桶参数;CompartmentID 空为生效 compartment。
|
||||||
|
type CreateBucketInput struct {
|
||||||
|
Name string
|
||||||
|
CompartmentID string
|
||||||
|
PublicRead bool
|
||||||
|
StorageTier string // Standard / Archive
|
||||||
|
VersioningOn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBucketInput 更新桶参数;nil 字段不改。
|
||||||
|
type UpdateBucketInput struct {
|
||||||
|
PublicRead *bool
|
||||||
|
VersioningOn *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePARInput 签发预签名请求参数。
|
||||||
|
type CreatePARInput struct {
|
||||||
|
Name string
|
||||||
|
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
|
||||||
|
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite
|
||||||
|
ExpiresHours int
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkRetryPolicy 批量删除(清空桶)的限流退避:16 并发下 OCI 可能回 429。
|
||||||
|
// 不用 DefaultRetryPolicy:其 8 次尝试、退避上限 30s,且带最终一致性模式
|
||||||
|
// (9 次、上限 45s)——大批量删除里 worker 一旦触发就长睡,吞吐塌方;
|
||||||
|
// 这里收紧为 5 次、上限 3s、关掉最终一致性,单条最坏 ~13s 后放行不拖全场。
|
||||||
|
var bulkRetryPolicy = common.NewRetryPolicyWithOptions(
|
||||||
|
common.ReplaceWithValuesFromRetryPolicy(common.DefaultRetryPolicyWithoutEventualConsistency()),
|
||||||
|
common.WithMaximumNumberAttempts(5),
|
||||||
|
common.WithExponentialBackoff(3*time.Second, 2.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *RealClient) osClient(cred Credentials, region string) (objectstorage.ObjectStorageClient, error) {
|
||||||
|
oc, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return oc, fmt.Errorf("new object storage client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&oc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
oc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return oc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObjectStorageNamespace 实现 Client:租户 namespace(全租户唯一,常量语义),
|
||||||
|
// 首次回源后按 tenancy 进程内缓存;此前每个对象存储操作都远程取一次,
|
||||||
|
// 经代理链路时白付一整次冷连接往返。并发首次可能重复回源,结果相同无害。
|
||||||
|
func (c *RealClient) GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||||
|
if v, ok := c.namespaces.Load(cred.TenancyOCID); ok {
|
||||||
|
return v.(string), nil
|
||||||
|
}
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := oc.GetNamespace(ctx, objectstorage.GetNamespaceRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get namespace: %w", err)
|
||||||
|
}
|
||||||
|
if cred.TenancyOCID != "" {
|
||||||
|
c.namespaces.Store(cred.TenancyOCID, deref(resp.Value))
|
||||||
|
}
|
||||||
|
return deref(resp.Value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBuckets 实现 Client:列出指定 compartment 下的桶并逐个补全用量(空为生效 compartment)。
|
||||||
|
func (c *RealClient) ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := oc.ListBuckets(ctx, objectstorage.ListBucketsRequest{
|
||||||
|
NamespaceName: &ns,
|
||||||
|
CompartmentId: hostCompartment(cred, compartmentID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list buckets: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Bucket, 0, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
out = append(out, c.bucketDetail(ctx, oc, ns, deref(it.Name)))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketDetail 取单桶详情;失败时退化为只有名字的摘要,不拖垮列表。
|
||||||
|
func (c *RealClient) bucketDetail(ctx context.Context, oc objectstorage.ObjectStorageClient, ns, name string) Bucket {
|
||||||
|
resp, err := oc.GetBucket(ctx, objectstorage.GetBucketRequest{
|
||||||
|
NamespaceName: &ns,
|
||||||
|
BucketName: &name,
|
||||||
|
Fields: []objectstorage.GetBucketFieldsEnum{"approximateCount", "approximateSize"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Bucket{Name: name, Namespace: ns}
|
||||||
|
}
|
||||||
|
b := Bucket{
|
||||||
|
Name: name,
|
||||||
|
Namespace: ns,
|
||||||
|
Visibility: string(resp.PublicAccessType),
|
||||||
|
StorageTier: string(resp.StorageTier),
|
||||||
|
VersioningOn: resp.Versioning == objectstorage.BucketVersioningEnabled,
|
||||||
|
ApproximateCount: derefI64(resp.ApproximateCount),
|
||||||
|
ApproximateSize: derefI64(resp.ApproximateSize),
|
||||||
|
}
|
||||||
|
if resp.TimeCreated != nil {
|
||||||
|
b.TimeCreated = &resp.TimeCreated.Time
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefI64(p *int64) int64 {
|
||||||
|
if p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBucket 实现 Client。
|
||||||
|
func (c *RealClient) CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Bucket{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Bucket{}, err
|
||||||
|
}
|
||||||
|
details := objectstorage.CreateBucketDetails{
|
||||||
|
Name: &in.Name,
|
||||||
|
CompartmentId: hostCompartment(cred, in.CompartmentID),
|
||||||
|
PublicAccessType: objectstorage.CreateBucketDetailsPublicAccessTypeNopublicaccess,
|
||||||
|
StorageTier: objectstorage.CreateBucketDetailsStorageTierStandard,
|
||||||
|
}
|
||||||
|
if in.PublicRead {
|
||||||
|
details.PublicAccessType = objectstorage.CreateBucketDetailsPublicAccessTypeObjectread
|
||||||
|
}
|
||||||
|
if strings.EqualFold(in.StorageTier, "Archive") {
|
||||||
|
details.StorageTier = objectstorage.CreateBucketDetailsStorageTierArchive
|
||||||
|
}
|
||||||
|
if in.VersioningOn {
|
||||||
|
details.Versioning = objectstorage.CreateBucketDetailsVersioningEnabled
|
||||||
|
}
|
||||||
|
if _, err := oc.CreateBucket(ctx, objectstorage.CreateBucketRequest{
|
||||||
|
NamespaceName: &ns, CreateBucketDetails: details,
|
||||||
|
}); err != nil {
|
||||||
|
return Bucket{}, fmt.Errorf("create bucket: %w", err)
|
||||||
|
}
|
||||||
|
return c.bucketDetail(ctx, oc, ns, in.Name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBucket 实现 Client:改可见性 / 版本控制。
|
||||||
|
func (c *RealClient) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Bucket{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Bucket{}, err
|
||||||
|
}
|
||||||
|
details := objectstorage.UpdateBucketDetails{}
|
||||||
|
if in.PublicRead != nil {
|
||||||
|
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeNopublicaccess
|
||||||
|
if *in.PublicRead {
|
||||||
|
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeObjectread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.VersioningOn != nil {
|
||||||
|
details.Versioning = objectstorage.UpdateBucketDetailsVersioningSuspended
|
||||||
|
if *in.VersioningOn {
|
||||||
|
details.Versioning = objectstorage.UpdateBucketDetailsVersioningEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := oc.UpdateBucket(ctx, objectstorage.UpdateBucketRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &name, UpdateBucketDetails: details,
|
||||||
|
}); err != nil {
|
||||||
|
return Bucket{}, fmt.Errorf("update bucket: %w", err)
|
||||||
|
}
|
||||||
|
return c.bucketDetail(ctx, oc, ns, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBucket 实现 Client:仅空桶可删,OCI 拒绝非空桶。
|
||||||
|
// ErrBucketNotEmpty 表示桶内仍有对象 / 历史版本 / 未完成分片上传,OCI 拒绝删除。
|
||||||
|
var ErrBucketNotEmpty = errors.New("bucket not empty")
|
||||||
|
|
||||||
|
func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := oc.DeleteBucket(ctx, objectstorage.DeleteBucketRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &name,
|
||||||
|
}); err != nil {
|
||||||
|
if isBucketNotEmptyErr(err) {
|
||||||
|
return fmt.Errorf("delete bucket %s: %w", name, ErrBucketNotEmpty)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delete bucket: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
|
||||||
|
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
|
||||||
|
// 两者都可经后台清空(对象版本+PAR)后重删。
|
||||||
|
func isBucketNotEmptyErr(err error) bool {
|
||||||
|
var se common.ServiceError
|
||||||
|
if !errors.As(err, &se) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if se.GetCode() == "BucketNotEmpty" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return se.GetHTTPStatusCode() == 409 &&
|
||||||
|
strings.Contains(se.GetMessage(), "Preauthenticated Request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectVersion 是对象版本摘要,清空桶时逐版本删除用。
|
||||||
|
type ObjectVersion struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
VersionID string `json:"versionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListObjectVersions 实现 Client:分页列出全部对象版本(未开版本控制的桶返回当前版本)。
|
||||||
|
func (c *RealClient) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req := objectstorage.ListObjectVersionsRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, Limit: common.Int(1000),
|
||||||
|
}
|
||||||
|
if page != "" {
|
||||||
|
req.Page = &page
|
||||||
|
}
|
||||||
|
resp, err := oc.ListObjectVersions(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("list object versions: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]ObjectVersion, 0, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
out = append(out, ObjectVersion{Name: deref(it.Name), VersionID: deref(it.VersionId)})
|
||||||
|
}
|
||||||
|
return out, deref(resp.OpcNextPage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObjectVersion 实现 Client:删除指定版本(versionID 空为当前版本)。
|
||||||
|
func (c *RealClient) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := objectstorage.DeleteObjectRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||||
|
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
|
||||||
|
}
|
||||||
|
if versionID != "" {
|
||||||
|
req.VersionId = &versionID
|
||||||
|
}
|
||||||
|
if _, err := oc.DeleteObject(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("delete object version: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListObjects 实现 Client:delimiter="/" 前缀模式;startWith 为上页游标。
|
||||||
|
func (c *RealClient) ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ListObjectsResult{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ListObjectsResult{}, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
req := objectstorage.ListObjectsRequest{
|
||||||
|
NamespaceName: &ns,
|
||||||
|
BucketName: &bucket,
|
||||||
|
Delimiter: common.String("/"),
|
||||||
|
Limit: &limit,
|
||||||
|
Fields: common.String("name,size,timeModified,storageTier,archivalState"),
|
||||||
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
req.Prefix = &prefix
|
||||||
|
}
|
||||||
|
if startWith != "" {
|
||||||
|
req.Start = &startWith
|
||||||
|
}
|
||||||
|
resp, err := oc.ListObjects(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return ListObjectsResult{}, fmt.Errorf("list objects: %w", err)
|
||||||
|
}
|
||||||
|
return toListObjectsResult(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toListObjectsResult(resp objectstorage.ListObjectsResponse) ListObjectsResult {
|
||||||
|
out := ListObjectsResult{
|
||||||
|
Objects: make([]ObjectSummary, 0, len(resp.Objects)),
|
||||||
|
Prefixes: orEmpty(resp.Prefixes),
|
||||||
|
NextStartWith: deref(resp.NextStartWith),
|
||||||
|
}
|
||||||
|
for _, o := range resp.Objects {
|
||||||
|
item := ObjectSummary{
|
||||||
|
Name: deref(o.Name),
|
||||||
|
Size: derefI64(o.Size),
|
||||||
|
StorageTier: string(o.StorageTier),
|
||||||
|
ArchivalState: string(o.ArchivalState),
|
||||||
|
}
|
||||||
|
if o.TimeModified != nil {
|
||||||
|
item.TimeModified = &o.TimeModified.Time
|
||||||
|
}
|
||||||
|
out.Objects = append(out.Objects, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObject 实现 Client。
|
||||||
|
func (c *RealClient) DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := oc.DeleteObject(ctx, objectstorage.DeleteObjectRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("delete object: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameObject 实现 Client:OCI 原生重命名(同桶内)。
|
||||||
|
func (c *RealClient) RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := oc.RenameObject(ctx, objectstorage.RenameObjectRequest{
|
||||||
|
NamespaceName: &ns,
|
||||||
|
BucketName: &bucket,
|
||||||
|
RenameObjectDetails: objectstorage.RenameObjectDetails{
|
||||||
|
SourceName: &src,
|
||||||
|
NewName: &dst,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("rename object: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreObject 实现 Client:Archive 对象取回,hours 为可下载时长(默认 24)。
|
||||||
|
func (c *RealClient) RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := objectstorage.RestoreObjectsRequest{
|
||||||
|
NamespaceName: &ns,
|
||||||
|
BucketName: &bucket,
|
||||||
|
RestoreObjectsDetails: objectstorage.RestoreObjectsDetails{
|
||||||
|
ObjectName: &object,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
req.RestoreObjectsDetails.Hours = &hours
|
||||||
|
}
|
||||||
|
if _, err := oc.RestoreObjects(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("restore object: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadObject 实现 Client:对象元数据。
|
||||||
|
func (c *RealClient) HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectDetail{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectDetail{}, err
|
||||||
|
}
|
||||||
|
resp, err := oc.HeadObject(ctx, objectstorage.HeadObjectRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ObjectDetail{}, fmt.Errorf("head object: %w", err)
|
||||||
|
}
|
||||||
|
d := ObjectDetail{
|
||||||
|
Name: object,
|
||||||
|
Size: derefI64(resp.ContentLength),
|
||||||
|
ContentType: deref(resp.ContentType),
|
||||||
|
Etag: deref(resp.ETag),
|
||||||
|
ContentMd5: deref(resp.ContentMd5),
|
||||||
|
StorageTier: string(resp.StorageTier),
|
||||||
|
ArchivalState: string(resp.ArchivalState),
|
||||||
|
Metadata: resp.OpcMeta,
|
||||||
|
}
|
||||||
|
if resp.LastModified != nil {
|
||||||
|
d.TimeModified = &resp.LastModified.Time
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObject 实现 Client:整体读入对象内容;超过 maxBytes 返回 ErrObjectTooLarge。
|
||||||
|
func (c *RealClient) GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectContent{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectContent{}, err
|
||||||
|
}
|
||||||
|
resp, err := oc.GetObject(ctx, objectstorage.GetObjectRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ObjectContent{}, fmt.Errorf("get object: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Content.Close()
|
||||||
|
if derefI64(resp.ContentLength) > maxBytes {
|
||||||
|
return ObjectContent{}, fmt.Errorf("get object %s (%d bytes): %w", object, derefI64(resp.ContentLength), ErrObjectTooLarge)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return ObjectContent{}, fmt.Errorf("read object body: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxBytes {
|
||||||
|
return ObjectContent{}, fmt.Errorf("get object %s: %w", object, ErrObjectTooLarge)
|
||||||
|
}
|
||||||
|
return ObjectContent{Data: data, ContentType: deref(resp.ContentType), Etag: deref(resp.ETag)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutObject 实现 Client:整体写入对象;ifMatch 非空时作为 If-Match 条件,
|
||||||
|
// 不匹配由 OCI 返回 412(经 ServiceError 透出)。返回新 ETag。
|
||||||
|
func (c *RealClient) PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
length := int64(len(data))
|
||||||
|
req := objectstorage.PutObjectRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||||
|
ContentLength: &length,
|
||||||
|
PutObjectBody: io.NopCloser(bytes.NewReader(data)),
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
req.ContentType = &contentType
|
||||||
|
}
|
||||||
|
if ifMatch != "" {
|
||||||
|
req.IfMatch = &ifMatch
|
||||||
|
}
|
||||||
|
resp, err := oc.PutObject(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("put object: %w", err)
|
||||||
|
}
|
||||||
|
return deref(resp.ETag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePAR 实现 Client:签发预签名请求并拼出完整 URL(仅此时可得)。
|
||||||
|
func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return PAR{}, err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return PAR{}, err
|
||||||
|
}
|
||||||
|
if in.ExpiresHours <= 0 {
|
||||||
|
in.ExpiresHours = 24
|
||||||
|
}
|
||||||
|
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
||||||
|
Name: &in.Name,
|
||||||
|
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
||||||
|
TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)},
|
||||||
|
}
|
||||||
|
if in.ObjectName != "" {
|
||||||
|
details.ObjectName = &in.ObjectName
|
||||||
|
}
|
||||||
|
// 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览
|
||||||
|
if strings.HasPrefix(in.AccessType, "AnyObject") {
|
||||||
|
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
||||||
|
}
|
||||||
|
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PAR{}, fmt.Errorf("create par: %w", err)
|
||||||
|
}
|
||||||
|
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
|
||||||
|
out := PAR{
|
||||||
|
ID: deref(p.Id),
|
||||||
|
Name: deref(p.Name),
|
||||||
|
ObjectName: deref(p.ObjectName),
|
||||||
|
AccessType: string(p.AccessType),
|
||||||
|
}
|
||||||
|
if p.AccessUri != nil {
|
||||||
|
out.FullURL = strings.TrimSuffix(endpoint, "/") + *p.AccessUri
|
||||||
|
}
|
||||||
|
if p.TimeExpires != nil {
|
||||||
|
out.TimeExpires = &p.TimeExpires.Time
|
||||||
|
}
|
||||||
|
if p.TimeCreated != nil {
|
||||||
|
out.TimeCreated = &p.TimeCreated.Time
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPARsPage 实现 Client:单页列出桶内预签名请求(摘要不含 URL),
|
||||||
|
// 返回下一页游标,空串表示已到末页;limit ≤0 时用 OCI 默认页大小。
|
||||||
|
func (c *RealClient) ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req := objectstorage.ListPreauthenticatedRequestsRequest{NamespaceName: &ns, BucketName: &bucket}
|
||||||
|
if limit > 0 {
|
||||||
|
req.Limit = common.Int(limit)
|
||||||
|
}
|
||||||
|
if page != "" {
|
||||||
|
req.Page = &page
|
||||||
|
}
|
||||||
|
resp, err := oc.ListPreauthenticatedRequests(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("list pars: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]PAR, 0, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
out = append(out, summaryPAR(it))
|
||||||
|
}
|
||||||
|
return out, deref(resp.OpcNextPage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPARs 实现 Client:翻页列出桶内全部有效预签名请求(清空/全部删除用)。
|
||||||
|
func (c *RealClient) ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) {
|
||||||
|
out := make([]PAR, 0, 16)
|
||||||
|
page := ""
|
||||||
|
for {
|
||||||
|
items, next, err := c.ListPARsPage(ctx, cred, region, bucket, page, 1000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, items...)
|
||||||
|
if next == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
page = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryPAR(p objectstorage.PreauthenticatedRequestSummary) PAR {
|
||||||
|
out := PAR{
|
||||||
|
ID: deref(p.Id),
|
||||||
|
Name: deref(p.Name),
|
||||||
|
ObjectName: deref(p.ObjectName),
|
||||||
|
AccessType: string(p.AccessType),
|
||||||
|
}
|
||||||
|
if p.TimeExpires != nil {
|
||||||
|
out.TimeExpires = &p.TimeExpires.Time
|
||||||
|
}
|
||||||
|
if p.TimeCreated != nil {
|
||||||
|
out.TimeCreated = &p.TimeCreated.Time
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePAR 实现 Client:撤销预签名请求,链接立即失效。
|
||||||
|
func (c *RealClient) DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := oc.DeletePreauthenticatedRequest(ctx, objectstorage.DeletePreauthenticatedRequestRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ParId: &parID,
|
||||||
|
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("delete par: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
|
||||||
|
// (凭据为空也能取到,证明未走回源路径)。
|
||||||
|
func TestGetObjectStorageNamespaceCached(t *testing.T) {
|
||||||
|
c := NewClient()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tenancy string
|
||||||
|
seed string
|
||||||
|
}{
|
||||||
|
{name: "缓存命中直接返回", tenancy: "ocid1.tenancy.oc1..aaaa", seed: "ns-a"},
|
||||||
|
{name: "不同租户各取各的", tenancy: "ocid1.tenancy.oc1..bbbb", seed: "ns-b"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c.namespaces.Store(tt.tenancy, tt.seed)
|
||||||
|
got, err := c.GetObjectStorageNamespace(context.Background(), Credentials{TenancyOCID: tt.tenancy}, "us-ashburn-1")
|
||||||
|
if err != nil || got != tt.seed {
|
||||||
|
t.Fatalf("GetObjectStorageNamespace = %q, %v; want %q", got, err, tt.seed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(次要网卡场景)。
|
||||||
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
|
func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) {
|
||||||
return "", fmt.Errorf("delete old public ip: %w", err)
|
vn, err := c.vcnClient(cred, region)
|
||||||
}
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceEphemeralPublicIP 释放私有 IP 上现有公网 IP(临时删除、保留解绑)并分配新临时 IP。
|
||||||
|
func replaceEphemeralPublicIP(ctx context.Context, vn core.VirtualNetworkClient, cred Credentials, priv core.PrivateIp) (string, error) {
|
||||||
|
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
|
// 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ type AiGatewayService struct {
|
|||||||
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
|
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
|
||||||
grokWebSearch atomic.Bool
|
grokWebSearch atomic.Bool
|
||||||
grokXSearch atomic.Bool
|
grokXSearch atomic.Bool
|
||||||
|
// upstreamWaitSec 是 responses 直通的上游无响应预算(秒):非流式为单次尝试
|
||||||
|
// 总超时,流式为等待响应头预算;multi-agent/搜索类模型远超 SDK 默认 60s
|
||||||
|
upstreamWaitSec atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||||
@@ -78,6 +81,7 @@ func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Clie
|
|||||||
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
|
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
|
||||||
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
|
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
|
||||||
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
|
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
|
||||||
|
s.upstreamWaitSec.Store(int64(loadIntSetting(db, settingAiUpstreamWaitSec, defaultUpstreamWaitSec)))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +97,17 @@ const (
|
|||||||
// 开关,缺省开。
|
// 开关,缺省开。
|
||||||
settingAiGrokWebSearch = "ai_grok_web_search"
|
settingAiGrokWebSearch = "ai_grok_web_search"
|
||||||
settingAiGrokXSearch = "ai_grok_x_search"
|
settingAiGrokXSearch = "ai_grok_x_search"
|
||||||
|
// settingAiUpstreamWaitSec 是 responses 直通的上游无响应预算(秒)。
|
||||||
|
settingAiUpstreamWaitSec = "ai_upstream_wait_seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
|
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
|
||||||
const defaultStreamGuardKB = 60
|
const defaultStreamGuardKB = 60
|
||||||
|
|
||||||
|
// defaultUpstreamWaitSec 是上游无响应预算缺省值(秒):multi-agent 非流式
|
||||||
|
// 实测 100~180s 才回响应头,给足余量;上下限见 SetUpstreamWait。
|
||||||
|
const defaultUpstreamWaitSec = 300
|
||||||
|
|
||||||
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
|
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
|
||||||
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
|
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
|
||||||
var row model.Setting
|
var row model.Setting
|
||||||
@@ -165,6 +175,25 @@ func (s *AiGatewayService) SetStreamGuard(ctx context.Context, on bool, kb int)
|
|||||||
return nil
|
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)。
|
// GrokSearch 返回 grok 服务端搜索工具默认注入开关(web_search, x_search)。
|
||||||
func (s *AiGatewayService) GrokSearch() (bool, bool) {
|
func (s *AiGatewayService) GrokSearch() (bool, bool) {
|
||||||
return s.grokWebSearch.Load(), s.grokXSearch.Load()
|
return s.grokWebSearch.Load(), s.grokXSearch.Load()
|
||||||
|
|||||||
@@ -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 形态响应。
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -1124,3 +1124,38 @@ func TestAggregatedModelsFilterDeprecated(t *testing.T) {
|
|||||||
t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 拒绝)。
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxInvoicePdfBytes 是发票 PDF 中转上限;正常发票远小于此,超限视为异常。
|
||||||
|
const maxInvoicePdfBytes = 20 << 20
|
||||||
|
|
||||||
|
// Invoices 列出租户发票;year>0 只取该自然年(前端按年懒加载),0 为全量。
|
||||||
|
func (s *OciConfigService) Invoices(ctx context.Context, id uint, year int) ([]oci.Invoice, error) {
|
||||||
|
if year != 0 && (year < 2000 || year > 2100) {
|
||||||
|
return nil, fmt.Errorf("invoices: invalid year %d", year)
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.ListInvoices(ctx, cred, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceLines 列出一张发票的费用明细。
|
||||||
|
func (s *OciConfigService) InvoiceLines(ctx context.Context, id uint, internalID string) ([]oci.InvoiceLine, error) {
|
||||||
|
if internalID == "" {
|
||||||
|
return nil, fmt.Errorf("invoice lines: internal invoice id is empty")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.ListInvoiceLines(ctx, cred, internalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoicePdf 拉取一张发票的 PDF 原文。
|
||||||
|
func (s *OciConfigService) InvoicePdf(ctx context.Context, id uint, internalID string) ([]byte, error) {
|
||||||
|
if internalID == "" {
|
||||||
|
return nil, fmt.Errorf("invoice pdf: internal invoice id is empty")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.DownloadInvoicePdf(ctx, cred, internalID, maxInvoicePdfBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayInvoice 按订阅默认付款方式支付发票;email 接收付款回执,必填。
|
||||||
|
func (s *OciConfigService) PayInvoice(ctx context.Context, id uint, internalID, email string) error {
|
||||||
|
if internalID == "" {
|
||||||
|
return fmt.Errorf("pay invoice: internal invoice id is empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(email, "@") {
|
||||||
|
return fmt.Errorf("pay invoice: invalid receipt email")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.PayInvoice(ctx, cred, internalID, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentMethods 列出订阅上登记的全部付款方式。
|
||||||
|
func (s *OciConfigService) PaymentMethods(ctx context.Context, id uint) ([]oci.PaymentMethod, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.ListPaymentMethods(ctx, cred)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// billingStubClient 只桩账单方法,其余经内嵌 fakeClient 兜底。
|
||||||
|
type billingStubClient struct {
|
||||||
|
*fakeClient
|
||||||
|
|
||||||
|
invoices []oci.Invoice
|
||||||
|
lines []oci.InvoiceLine
|
||||||
|
methods []oci.PaymentMethod
|
||||||
|
pdf []byte
|
||||||
|
|
||||||
|
listYear int
|
||||||
|
paidID string
|
||||||
|
paidEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *billingStubClient) ListInvoices(ctx context.Context, cred oci.Credentials, year int) ([]oci.Invoice, error) {
|
||||||
|
f.listYear = year
|
||||||
|
return f.invoices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *billingStubClient) ListInvoiceLines(ctx context.Context, cred oci.Credentials, internalInvoiceID string) ([]oci.InvoiceLine, error) {
|
||||||
|
return f.lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *billingStubClient) DownloadInvoicePdf(ctx context.Context, cred oci.Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
|
||||||
|
return f.pdf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *billingStubClient) PayInvoice(ctx context.Context, cred oci.Credentials, internalInvoiceID, email string) error {
|
||||||
|
f.paidID, f.paidEmail = internalInvoiceID, email
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *billingStubClient) ListPaymentMethods(ctx context.Context, cred oci.Credentials) ([]oci.PaymentMethod, error) {
|
||||||
|
return f.methods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvoicesAndPaymentMethods(t *testing.T) {
|
||||||
|
client := &billingStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
invoices: []oci.Invoice{{ID: "inv1", Number: "100001", Status: "OPEN"}},
|
||||||
|
lines: []oci.InvoiceLine{{Product: "Compute", Total: 12.5}},
|
||||||
|
methods: []oci.PaymentMethod{{Method: "CREDIT_CARD", LastDigits: "4242"}},
|
||||||
|
pdf: []byte("%PDF-1.4 fake"),
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if list, err := svc.Invoices(ctx, cfg.ID, 0); err != nil || len(list) != 1 || list[0].Number != "100001" {
|
||||||
|
t.Fatalf("Invoices = %v, %v", list, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Invoices(ctx, cfg.ID, 2026); err != nil || client.listYear != 2026 {
|
||||||
|
t.Fatalf("Invoices(year) 透传 = %d, %v, want 2026", client.listYear, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Invoices(ctx, cfg.ID, 26); err == nil {
|
||||||
|
t.Fatal("Invoices(26) 应拒绝非法年份")
|
||||||
|
}
|
||||||
|
if lines, err := svc.InvoiceLines(ctx, cfg.ID, "6100"); err != nil || len(lines) != 1 {
|
||||||
|
t.Fatalf("InvoiceLines = %v, %v", lines, err)
|
||||||
|
}
|
||||||
|
if data, err := svc.InvoicePdf(ctx, cfg.ID, "6100"); err != nil || len(data) == 0 {
|
||||||
|
t.Fatalf("InvoicePdf = %d bytes, %v", len(data), err)
|
||||||
|
}
|
||||||
|
if ms, err := svc.PaymentMethods(ctx, cfg.ID); err != nil || len(ms) != 1 || ms[0].LastDigits != "4242" {
|
||||||
|
t.Fatalf("PaymentMethods = %v, %v", ms, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayInvoiceValidation(t *testing.T) {
|
||||||
|
client := &billingStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name, internalID, email, wantErr string
|
||||||
|
}{
|
||||||
|
{"内部 ID 为空", "", "a@b.c", "internal invoice id is empty"},
|
||||||
|
{"邮箱非法", "6100", "not-an-email", "invalid receipt email"},
|
||||||
|
{"合法请求", "6100", "billing@example.com", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := svc.PayInvoice(ctx, cfg.ID, tt.internalID, tt.email)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if client.paidID != tt.internalID || client.paidEmail != tt.email {
|
||||||
|
t.Fatalf("透传 = (%s, %s), want (%s, %s)", client.paidID, client.paidEmail, tt.internalID, tt.email)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
|
|||||||
if count < 1 || count > maxBatchCreate {
|
if count < 1 || count > maxBatchCreate {
|
||||||
return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate)
|
return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate)
|
||||||
}
|
}
|
||||||
|
if in.ReservedPublicIPID != "" && count > 1 {
|
||||||
|
return nil, nil, fmt.Errorf("create instances: reserved public ip only supports single instance")
|
||||||
|
}
|
||||||
if err := validateCreateInstance(in); err != nil {
|
if err := validateCreateInstance(in); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,10 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
|
|||||||
failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err)))
|
failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if one.ReservedPublicIPID != "" {
|
||||||
|
region, instanceID, ipID := one.Region, instance.ID, one.ReservedPublicIPID
|
||||||
|
s.goBind(func() { s.bindReservedIPWhenReady(cred, region, instanceID, ipID) })
|
||||||
|
}
|
||||||
instances = append(instances, instance)
|
instances = append(instances, instance)
|
||||||
}
|
}
|
||||||
return instances, failures, nil
|
return instances, failures, nil
|
||||||
|
|||||||
@@ -554,6 +554,8 @@ func envActor(env onsEnvelope) string {
|
|||||||
|
|
||||||
// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余
|
// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余
|
||||||
// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。
|
// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。
|
||||||
|
// 计算类失败形如 "LaunchInstance failed with response 'NotAuthorizedOrNotFound'",
|
||||||
|
// 判失败并以引号内错误码作补充说明(无其他说明时)。
|
||||||
func envOutcome(env onsEnvelope) (outcome, detail string) {
|
func envOutcome(env onsEnvelope) (outcome, detail string) {
|
||||||
if env.StateChange != nil {
|
if env.StateChange != nil {
|
||||||
detail = env.StateChange.Current.Description
|
detail = env.StateChange.Current.Description
|
||||||
@@ -566,10 +568,24 @@ func envOutcome(env onsEnvelope) (outcome, detail string) {
|
|||||||
outcome = "成功"
|
outcome = "成功"
|
||||||
case strings.HasSuffix(env.Message, " failed"):
|
case strings.HasSuffix(env.Message, " failed"):
|
||||||
outcome = "失败"
|
outcome = "失败"
|
||||||
|
case strings.Contains(env.Message, " failed with response "):
|
||||||
|
outcome = "失败"
|
||||||
|
if detail == "" {
|
||||||
|
detail = failedResponse(env.Message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return outcome, detail
|
return outcome, detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// failedResponse 提取 "X failed with response 'Err'" 中引号内的错误码。
|
||||||
|
func failedResponse(msg string) string {
|
||||||
|
_, after, ok := strings.Cut(msg, " failed with response ")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Trim(strings.TrimSpace(after), "'")
|
||||||
|
}
|
||||||
|
|
||||||
// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败,
|
// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败,
|
||||||
// 失败时以其 message 作为原因说明。
|
// 失败时以其 message 作为原因说明。
|
||||||
func ssoOutcome(raw, detail string) (string, string) {
|
func ssoOutcome(raw, detail string) (string, string) {
|
||||||
|
|||||||
@@ -308,6 +308,14 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
wantType: "x",
|
wantType: "x",
|
||||||
wantOutcome: "失败",
|
wantOutcome: "失败",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "failed with response 判失败并提取错误码",
|
||||||
|
payload: `{"type":"com.oraclecloud.computeApi.LaunchInstance.begin","data":{
|
||||||
|
"message":"LaunchInstance failed with response 'NotAuthorizedOrNotFound'"}}`,
|
||||||
|
wantType: "com.oraclecloud.computeApi.LaunchInstance.begin",
|
||||||
|
wantOutcome: "失败",
|
||||||
|
wantDetail: "NotAuthorizedOrNotFound",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,9 +24,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RelayCriticalEvents 是回传的关键事件清单(Connector Log Filter 与 P2 告警共用):
|
// RelayCriticalEvents 是回传的关键事件清单(Connector Log Filter 与 P2 告警共用):
|
||||||
// 实例生命周期、用户与凭据、区域订阅、策略变更、控制台登录。
|
// 实例终止与电源操作、用户与凭据、区域订阅、策略变更、控制台登录。
|
||||||
|
// 不含 LaunchInstance:创建以面板自身(手动/抢机反复尝试)发起为主,回传即噪声
|
||||||
|
// (10 个 1min 抢机任务一天即可刷掉 2 万条上限),终止与电源操作才是外部风险信号。
|
||||||
var RelayCriticalEvents = []string{
|
var RelayCriticalEvents = []string{
|
||||||
"LaunchInstance", "TerminateInstance", "InstanceAction",
|
"TerminateInstance", "InstanceAction",
|
||||||
"CreateUser", "DeleteUser", "UpdateUser",
|
"CreateUser", "DeleteUser", "UpdateUser",
|
||||||
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities",
|
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities",
|
||||||
"CreateRegionSubscription",
|
"CreateRegionSubscription",
|
||||||
@@ -46,7 +49,7 @@ var relayCriticalSet = func() map[string]bool {
|
|||||||
// 通知管理「云端事件」按此细分开关。
|
// 通知管理「云端事件」按此细分开关。
|
||||||
func relayEventClass(name string) string {
|
func relayEventClass(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "LaunchInstance", "TerminateInstance", "InstanceAction":
|
case "TerminateInstance", "InstanceAction":
|
||||||
return "instance"
|
return "instance"
|
||||||
case "CreateUser", "DeleteUser", "UpdateUser",
|
case "CreateUser", "DeleteUser", "UpdateUser",
|
||||||
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities":
|
"CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities":
|
||||||
@@ -313,24 +316,30 @@ func (s *LogEventService) teardownResources(ctx context.Context, cred oci.Creden
|
|||||||
|
|
||||||
// ---- P2 告警联动:解析出关键事件后经 Notifier 推送 ----
|
// ---- P2 告警联动:解析出关键事件后经 Notifier 推送 ----
|
||||||
|
|
||||||
// relayEventShortName 取 CloudEvents type 末段(com.oraclecloud.ComputeApi.LaunchInstance → LaunchInstance)。
|
// relayEventShortName 取 CloudEvents type 中的事件短名:Audit v2 计算类事件带
|
||||||
|
// .begin/.end 阶段后缀(com.oraclecloud.ComputeApi.LaunchInstance.end),先剥阶段再取末段。
|
||||||
func relayEventShortName(eventType string) string {
|
func relayEventShortName(eventType string) string {
|
||||||
if i := strings.LastIndex(eventType, "."); i >= 0 {
|
t := strings.TrimSuffix(strings.TrimSuffix(eventType, ".begin"), ".end")
|
||||||
return eventType[i+1:]
|
if i := strings.LastIndex(t, "."); i >= 0 {
|
||||||
|
return t[i+1:]
|
||||||
}
|
}
|
||||||
return eventType
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
||||||
|
// 成对事件只推 .begin(携带操作者/IP/成败),.end 无操作者且信息重复,不再告警;
|
||||||
// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。
|
// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。
|
||||||
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
||||||
|
if strings.HasSuffix(p.EventType, ".end") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
name := relayEventShortName(p.EventType)
|
name := relayEventShortName(p.EventType)
|
||||||
if name == "" || !relayCriticalSet[name] {
|
if name == "" || !relayCriticalSet[name] {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"tenant": alias, "event": name,
|
"tenant": alias, "event": name,
|
||||||
"resource": orDash(p.ResourceName), "actor": orDash(p.Actor),
|
"resource": orDash(cmp.Or(p.ResourceName, p.Source)), "actor": orDash(p.Actor),
|
||||||
"ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome),
|
"ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome),
|
||||||
"detail": detailLine(p.Detail),
|
"detail": detailLine(p.Detail),
|
||||||
}, true
|
}, true
|
||||||
|
|||||||
@@ -339,10 +339,22 @@ func TestCriticalEventText(t *testing.T) {
|
|||||||
wantOK: true,
|
wantOK: true,
|
||||||
want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"},
|
want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "begin 阶段剥后缀命中并回退 source 作资源",
|
||||||
|
event: parsedEvent{EventType: "com.oraclecloud.computeApi.TerminateInstance.begin",
|
||||||
|
Source: "instance-20260717-1445", Actor: "IT Team", SourceIP: "137.131.7.136", Outcome: "成功"},
|
||||||
|
wantOK: true,
|
||||||
|
want: map[string]string{"event": "TerminateInstance", "resource": "instance-20260717-1445",
|
||||||
|
"actor": "IT Team", "ip": "137.131.7.136", "outcome": "成功"},
|
||||||
|
},
|
||||||
|
{name: "end 阶段不重复告警",
|
||||||
|
event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance.end"}, wantOK: false},
|
||||||
|
{name: "LaunchInstance 已移出清单不告警",
|
||||||
|
event: parsedEvent{EventType: "com.oraclecloud.computeApi.LaunchInstance.begin"}, wantOK: false},
|
||||||
{name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
|
{name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
|
||||||
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
|
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
|
||||||
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true,
|
{name: "短名直接命中", event: parsedEvent{EventType: "InstanceAction"}, wantOK: true,
|
||||||
want: map[string]string{"event": "LaunchInstance"}},
|
want: map[string]string{"event": "InstanceAction"}},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -370,7 +382,7 @@ func TestRelayEventClass(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
class string
|
class string
|
||||||
}{
|
}{
|
||||||
{"LaunchInstance", "instance"},
|
{"LaunchInstance", ""}, // 已移出回传清单,不归类
|
||||||
{"TerminateInstance", "instance"},
|
{"TerminateInstance", "instance"},
|
||||||
{"InstanceAction", "instance"},
|
{"InstanceAction", "instance"},
|
||||||
{"CreateUser", "identity"},
|
{"CreateUser", "identity"},
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObjectStorageNamespace 查询租户 namespace。
|
||||||
|
func (s *OciConfigService) ObjectStorageNamespace(ctx context.Context, id uint, region string) (string, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.client.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buckets 列出指定区间的存储桶(compartmentID 空为生效 compartment)。
|
||||||
|
func (s *OciConfigService) Buckets(ctx context.Context, id uint, region, compartmentID string) ([]oci.Bucket, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.ListBuckets(ctx, cred, region, compartmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBucket 创建存储桶,名称做基本合法性校验。
|
||||||
|
func (s *OciConfigService) CreateBucket(ctx context.Context, id uint, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
|
||||||
|
if err := validateBucketName(in.Name); err != nil {
|
||||||
|
return oci.Bucket{}, err
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.Bucket{}, err
|
||||||
|
}
|
||||||
|
return s.client.CreateBucket(ctx, cred, region, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBucketName 桶名规则:1-256 位字母数字连字符下划线句点。
|
||||||
|
func validateBucketName(name string) error {
|
||||||
|
if name == "" || len(name) > 256 {
|
||||||
|
return fmt.Errorf("create bucket: name length must be 1-256")
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
ok := r == '-' || r == '_' || r == '.' ||
|
||||||
|
(r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("create bucket: invalid character %q in name", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBucket 更新桶可见性 / 版本控制。
|
||||||
|
func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, name string, in oci.UpdateBucketInput) (oci.Bucket, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.Bucket{}, err
|
||||||
|
}
|
||||||
|
return s.client.UpdateBucket(ctx, cred, region, name, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除,
|
||||||
|
// 返回 queued=true 表示已排队。
|
||||||
|
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
err = s.client.DeleteBucket(ctx, cred, region, name)
|
||||||
|
if err == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, oci.ErrBucketNotEmpty) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
go s.purgeAndDeleteBucket(cred, region, name)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
|
||||||
|
const purgeBucketTimeout = 30 * time.Minute
|
||||||
|
|
||||||
|
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
|
||||||
|
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
|
||||||
|
log.Printf("[bucket-purge] %s purge objects failed: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
|
||||||
|
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
|
||||||
|
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
|
||||||
|
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
|
||||||
|
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||||
|
start, total := time.Now(), 0
|
||||||
|
for {
|
||||||
|
versions, next, err := s.client.ListObjectVersions(ctx, cred, region, name, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = forEachConcurrently(bulkDeleteWorkers, versions, func(v oci.ObjectVersion) error {
|
||||||
|
return s.client.DeleteObjectVersion(ctx, cred, region, name, v.Name, v.VersionID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += len(versions)
|
||||||
|
// 每轮都从首页重新列:删除后游标失效,直到列表为空
|
||||||
|
if next == "" && len(versions) < 1000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
log.Printf("[bucket-purge] %s purged %d object versions in %s", name, total, time.Since(start).Round(time.Second))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeBucketPARs 并发删除桶内全部预签名请求(与手动「全部删除」同一路径);
|
||||||
|
// 记录成功数/总数与耗时,速率长期顶在 ~10/s 即为 OCI 服务端限流。
|
||||||
|
func (s *OciConfigService) purgeBucketPARs(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||||
|
start := time.Now()
|
||||||
|
pars, err := s.client.ListPARs(ctx, cred, region, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(pars) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deleted, err := s.deletePARsConcurrently(ctx, cred, region, name, pars)
|
||||||
|
log.Printf("[bucket-purge] %s purged %d/%d pars in %s", name, deleted, len(pars), time.Since(start).Round(time.Second))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objects 分页列出对象(delimiter 前缀模式)。
|
||||||
|
func (s *OciConfigService) Objects(ctx context.Context, id uint, region, bucket, prefix, startWith string, limit int) (oci.ListObjectsResult, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.ListObjectsResult{}, err
|
||||||
|
}
|
||||||
|
return s.client.ListObjects(ctx, cred, region, bucket, prefix, startWith, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObject 删除对象。
|
||||||
|
func (s *OciConfigService) DeleteObject(ctx context.Context, id uint, region, bucket, object string) error {
|
||||||
|
if object == "" {
|
||||||
|
return fmt.Errorf("delete object: object name is required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.DeleteObject(ctx, cred, region, bucket, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameObject 重命名对象。
|
||||||
|
func (s *OciConfigService) RenameObject(ctx context.Context, id uint, region, bucket, src, dst string) error {
|
||||||
|
if src == "" || dst == "" || src == dst {
|
||||||
|
return fmt.Errorf("rename object: source and distinct new name are required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.RenameObject(ctx, cred, region, bucket, src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreObject 取回 Archive 对象。
|
||||||
|
func (s *OciConfigService) RestoreObject(ctx context.Context, id uint, region, bucket, object string, hours int) error {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.RestoreObject(ctx, cred, region, bucket, object, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectDetail 查询对象元数据。
|
||||||
|
func (s *OciConfigService) ObjectDetail(ctx context.Context, id uint, region, bucket, object string) (oci.ObjectDetail, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.ObjectDetail{}, err
|
||||||
|
}
|
||||||
|
return s.client.HeadObject(ctx, cred, region, bucket, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象内容中转上限:GET 服务预览(office 文档可达数 MB),PUT 服务文本编辑保存。
|
||||||
|
const (
|
||||||
|
maxObjectGetBytes = 20 << 20
|
||||||
|
maxObjectPutBytes = 5 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObjectContent 读取对象内容(面板中转,预览/编辑用,不签发 PAR)。
|
||||||
|
func (s *OciConfigService) ObjectContent(ctx context.Context, id uint, region, bucket, object string) (oci.ObjectContent, error) {
|
||||||
|
if object == "" {
|
||||||
|
return oci.ObjectContent{}, fmt.Errorf("get object content: object name is required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.ObjectContent{}, err
|
||||||
|
}
|
||||||
|
return s.client.GetObject(ctx, cred, region, bucket, object, maxObjectGetBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutObjectContent 保存对象内容(面板中转;ifMatch 冲突时 OCI 返回 412 透出)。
|
||||||
|
func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
|
||||||
|
if object == "" {
|
||||||
|
return "", fmt.Errorf("put object content: object name is required")
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxObjectPutBytes {
|
||||||
|
return "", fmt.Errorf("put object content %s: %w", object, oci.ErrObjectTooLarge)
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.client.PutObject(ctx, cred, region, bucket, object, data, contentType, ifMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parAccessTypes 是允许签发的 PAR 访问类型。
|
||||||
|
var parAccessTypes = map[string]bool{
|
||||||
|
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天。
|
||||||
|
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
||||||
|
if !parAccessTypes[in.AccessType] {
|
||||||
|
return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType)
|
||||||
|
}
|
||||||
|
if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 {
|
||||||
|
return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.PAR{}, err
|
||||||
|
}
|
||||||
|
return s.client.CreatePAR(ctx, cred, region, bucket, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PARsPage 分页列出桶内预签名请求;limit 归一到 1-1000,默认 100。
|
||||||
|
func (s *OciConfigService) PARsPage(ctx context.Context, id uint, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
|
||||||
|
if limit <= 0 || limit > 1000 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return s.client.ListPARsPage(ctx, cred, region, bucket, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePAR 撤销预签名请求。
|
||||||
|
func (s *OciConfigService) DeletePAR(ctx context.Context, id uint, region, bucket, parID string) error {
|
||||||
|
if parID == "" {
|
||||||
|
return fmt.Errorf("delete par: parId is required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.DeletePAR(ctx, cred, region, bucket, parID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllPARs 删除桶的全部预签名请求,返回成功删除数量;
|
||||||
|
// 个别失败不中断其余删除,返回首个错误供上层提示。
|
||||||
|
func (s *OciConfigService) DeleteAllPARs(ctx context.Context, id uint, region, bucket string) (int, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
pars, err := s.client.ListPARs(ctx, cred, region, bucket)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.deletePARsConcurrently(ctx, cred, region, bucket, pars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkDeleteWorkers 批量删除并发度;OCI 无批量接口,串行逐条会到分钟级。
|
||||||
|
// 16 配合请求级 429/5xx 退避重试(internal/oci)在限流与吞吐间取平衡,不再往上提。
|
||||||
|
const bulkDeleteWorkers = 16
|
||||||
|
|
||||||
|
// forEachConcurrently 以固定并发度对 items 逐个执行 fn;
|
||||||
|
// 个别失败不中断其余执行,返回首个错误。
|
||||||
|
func forEachConcurrently[T any](workers int, items []T, fn func(T) error) error {
|
||||||
|
// 信号量按并发度限流,容量=workers(规范允许的注释说明场景)
|
||||||
|
sem := make(chan struct{}, workers)
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
firstErr error
|
||||||
|
)
|
||||||
|
for _, it := range items {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(it T) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
if err := fn(it); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(it)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletePARsConcurrently 并发删除给定 PAR,返回成功删除数量与首个错误。
|
||||||
|
func (s *OciConfigService) deletePARsConcurrently(ctx context.Context, cred oci.Credentials, region, bucket string, pars []oci.PAR) (int, error) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
deleted int
|
||||||
|
)
|
||||||
|
err := forEachConcurrently(bulkDeleteWorkers, pars, func(p oci.PAR) error {
|
||||||
|
if err := s.client.DeletePAR(ctx, cred, region, bucket, p.ID); err != nil {
|
||||||
|
return fmt.Errorf("delete par %s: %w", p.Name, err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
deleted++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
type osStubClient struct {
|
||||||
|
*fakeClient
|
||||||
|
|
||||||
|
buckets []oci.Bucket
|
||||||
|
createdPAR oci.CreatePARInput
|
||||||
|
renamed [2]string
|
||||||
|
|
||||||
|
// 对象内容中转 stub 状态
|
||||||
|
objectData []byte
|
||||||
|
objectEtag string
|
||||||
|
putContentType string
|
||||||
|
putIfMatch string
|
||||||
|
|
||||||
|
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
|
||||||
|
mu sync.Mutex
|
||||||
|
versions []oci.ObjectVersion
|
||||||
|
pars []oci.PAR
|
||||||
|
bucketDeleted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
|
||||||
|
return f.buckets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) CreateBucket(ctx context.Context, cred oci.Credentials, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
|
||||||
|
b := oci.Bucket{Name: in.Name, Namespace: "ns", VersioningOn: in.VersioningOn}
|
||||||
|
f.buckets = append(f.buckets, b)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) RenameObject(ctx context.Context, cred oci.Credentials, region, bucket, src, dst string) error {
|
||||||
|
f.renamed = [2]string{src, dst}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
// 模拟 OCI 真实行为:残留对象版本或活跃 PAR 都会拒绝删桶
|
||||||
|
if len(f.versions) > 0 || len(f.pars) > 0 {
|
||||||
|
return fmt.Errorf("delete bucket %s: %w", name, oci.ErrBucketNotEmpty)
|
||||||
|
}
|
||||||
|
f.bucketDeleted = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) DeleteObjectVersion(ctx context.Context, cred oci.Credentials, region, bucket, object, versionID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
kept := f.versions[:0]
|
||||||
|
for _, v := range f.versions {
|
||||||
|
if !(v.Name == object && v.VersionID == versionID) {
|
||||||
|
kept = append(kept, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.versions = kept
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) ListPARs(ctx context.Context, cred oci.Credentials, region, bucket string) ([]oci.PAR, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]oci.PAR(nil), f.pars...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPARsPage 以数组下标模拟游标:page 为起始下标,next 为下一段起始下标。
|
||||||
|
func (f *osStubClient) ListPARsPage(ctx context.Context, cred oci.Credentials, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
start, _ := strconv.Atoi(page)
|
||||||
|
if start >= len(f.pars) || limit <= 0 {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
next := ""
|
||||||
|
if end < len(f.pars) {
|
||||||
|
next = strconv.Itoa(end)
|
||||||
|
} else {
|
||||||
|
end = len(f.pars)
|
||||||
|
}
|
||||||
|
return append([]oci.PAR(nil), f.pars[start:end]...), next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) DeletePAR(ctx context.Context, cred oci.Credentials, region, bucket, parID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
kept := f.pars[:0]
|
||||||
|
for _, p := range f.pars {
|
||||||
|
if p.ID != parID {
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.pars = kept
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) purgeState() (deleted bool, versions, pars int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.bucketDeleted, len(f.versions), len(f.pars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) GetObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, maxBytes int64) (oci.ObjectContent, error) {
|
||||||
|
if int64(len(f.objectData)) > maxBytes {
|
||||||
|
return oci.ObjectContent{}, fmt.Errorf("get object %s: %w", object, oci.ErrObjectTooLarge)
|
||||||
|
}
|
||||||
|
return oci.ObjectContent{Data: f.objectData, ContentType: "text/plain", Etag: f.objectEtag}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) PutObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
|
||||||
|
f.objectData = data
|
||||||
|
f.putContentType = contentType
|
||||||
|
f.putIfMatch = ifMatch
|
||||||
|
return "etag-new", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) CreatePAR(ctx context.Context, cred oci.Credentials, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
||||||
|
f.createdPAR = in
|
||||||
|
return oci.PAR{ID: "par-1", Name: in.Name, AccessType: in.AccessType, FullURL: "https://os.example/p/x/n/ns/b/o"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectStorageValidation(t *testing.T) {
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "桶名非法字符", run: func() error {
|
||||||
|
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "有中文"})
|
||||||
|
return err
|
||||||
|
}, wantErr: "invalid character"},
|
||||||
|
{name: "桶名空", run: func() error {
|
||||||
|
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: ""})
|
||||||
|
return err
|
||||||
|
}, wantErr: "length"},
|
||||||
|
{name: "合法桶名", run: func() error {
|
||||||
|
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "my-bucket_01"})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "重命名缺参", run: func() error {
|
||||||
|
return svc.RenameObject(ctx, cfg.ID, "r", "b", "a.txt", "a.txt")
|
||||||
|
}, wantErr: "distinct"},
|
||||||
|
{name: "PAR 类型不支持", run: func() error {
|
||||||
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
|
||||||
|
return err
|
||||||
|
}, wantErr: "unsupported access type"},
|
||||||
|
{name: "PAR 时长越界", run: func() error {
|
||||||
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
|
||||||
|
return err
|
||||||
|
}, wantErr: "1-720"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.run()
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteBucketEmptySync(t *testing.T) {
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||||
|
if err != nil || queued {
|
||||||
|
t.Fatalf("DeleteBucket 空桶 = queued %v, %v, want 同步删除", queued, err)
|
||||||
|
}
|
||||||
|
if deleted, _, _ := client.purgeState(); !deleted {
|
||||||
|
t.Error("空桶应同步删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteBucketPurgeQueued(t *testing.T) {
|
||||||
|
client := &osStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
versions: []oci.ObjectVersion{
|
||||||
|
{Name: "a.txt", VersionID: "v1"},
|
||||||
|
{Name: "a.txt", VersionID: ""},
|
||||||
|
{Name: "dir/b.bin", VersionID: "v9"},
|
||||||
|
},
|
||||||
|
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}},
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||||
|
if err != nil || !queued {
|
||||||
|
t.Fatalf("DeleteBucket 非空桶 = queued %v, %v, want queued", queued, err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if deleted, nv, np := client.purgeState(); deleted && nv == 0 && np == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
deleted, nv, np := client.purgeState()
|
||||||
|
t.Fatalf("purge 未完成: deleted=%v versions=%d pars=%d", deleted, nv, np)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBucketPAROnlyQueued 覆盖真实环境踩到的场景:桶内无对象,
|
||||||
|
// 仅剩活跃 PAR 时 OCI 也拒绝删桶,应同样走后台清空后重删。
|
||||||
|
func TestDeleteBucketPAROnlyQueued(t *testing.T) {
|
||||||
|
client := &osStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}},
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||||
|
if err != nil || !queued {
|
||||||
|
t.Fatalf("DeleteBucket 仅 PAR 桶 = queued %v, %v, want queued", queued, err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if deleted, _, np := client.purgeState(); deleted && np == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
deleted, _, np := client.purgeState()
|
||||||
|
t.Fatalf("purge 未完成: deleted=%v pars=%d", deleted, np)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAllPARsConcurrent(t *testing.T) {
|
||||||
|
pars := make([]oci.PAR, 40)
|
||||||
|
for i := range pars {
|
||||||
|
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i), Name: fmt.Sprintf("par-%d", i)}
|
||||||
|
}
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
n, err := svc.DeleteAllPARs(context.Background(), cfg.ID, "r", "b")
|
||||||
|
if err != nil || n != 40 {
|
||||||
|
t.Fatalf("DeleteAllPARs = %d, %v, want 40 deleted", n, err)
|
||||||
|
}
|
||||||
|
if _, _, np := client.purgeState(); np != 0 {
|
||||||
|
t.Errorf("remaining pars = %d, want 0", np)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPARsPage(t *testing.T) {
|
||||||
|
pars := make([]oci.PAR, 5)
|
||||||
|
for i := range pars {
|
||||||
|
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i)}
|
||||||
|
}
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
page string
|
||||||
|
limit int
|
||||||
|
wantIDs int
|
||||||
|
wantNext string
|
||||||
|
}{
|
||||||
|
{name: "首页带游标", page: "", limit: 2, wantIDs: 2, wantNext: "2"},
|
||||||
|
{name: "中间页", page: "2", limit: 2, wantIDs: 2, wantNext: "4"},
|
||||||
|
{name: "末页无游标", page: "4", limit: 2, wantIDs: 1, wantNext: ""},
|
||||||
|
{name: "limit 0 归一为 100", page: "", limit: 0, wantIDs: 5, wantNext: ""},
|
||||||
|
{name: "limit 越界归一为 100", page: "", limit: 5000, wantIDs: 5, wantNext: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
items, next, err := svc.PARsPage(context.Background(), cfg.ID, "r", "b", tt.page, tt.limit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PARsPage: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != tt.wantIDs || next != tt.wantNext {
|
||||||
|
t.Fatalf("PARsPage = %d 条, next=%q; want %d 条, next=%q", len(items), next, tt.wantIDs, tt.wantNext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForEachConcurrently(t *testing.T) {
|
||||||
|
items := make([]int, 20)
|
||||||
|
for i := range items {
|
||||||
|
items[i] = i
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
items []int
|
||||||
|
failOn int // 值为 -1 表示不注入失败
|
||||||
|
wantErr bool
|
||||||
|
wantDone int
|
||||||
|
}{
|
||||||
|
{name: "全部成功", items: items, failOn: -1, wantDone: 20},
|
||||||
|
{name: "个别失败不中断其余", items: items, failOn: 3, wantErr: true, wantDone: 19},
|
||||||
|
{name: "空列表", items: nil, failOn: -1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
done int
|
||||||
|
)
|
||||||
|
err := forEachConcurrently(4, tt.items, func(i int) error {
|
||||||
|
if i == tt.failOn {
|
||||||
|
return errors.New("boom")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
done++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if (err != nil) != tt.wantErr || done != tt.wantDone {
|
||||||
|
t.Fatalf("forEachConcurrently err=%v done=%d, want err=%v done=%d", err, done, tt.wantErr, tt.wantDone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectContentRoundTrip(t *testing.T) {
|
||||||
|
client := &osStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
objectData: []byte("hello"), objectEtag: "e1",
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", "a.txt")
|
||||||
|
if err != nil || string(got.Data) != "hello" || got.Etag != "e1" {
|
||||||
|
t.Fatalf("ObjectContent = %q etag=%q, %v; want hello/e1", got.Data, got.Etag, err)
|
||||||
|
}
|
||||||
|
etag, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a.txt", []byte("world"), "text/plain", "e1")
|
||||||
|
if err != nil || etag != "etag-new" {
|
||||||
|
t.Fatalf("PutObjectContent = %q, %v; want etag-new", etag, err)
|
||||||
|
}
|
||||||
|
if client.putIfMatch != "e1" || client.putContentType != "text/plain" {
|
||||||
|
t.Errorf("ifMatch=%q contentType=%q, want e1/text-plain 透传", client.putIfMatch, client.putContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectContentLimits(t *testing.T) {
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", ""); err == nil {
|
||||||
|
t.Error("空对象名应报错")
|
||||||
|
}
|
||||||
|
big := make([]byte, maxObjectPutBytes+1)
|
||||||
|
if _, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a", big, "", ""); !errors.Is(err, oci.ErrObjectTooLarge) {
|
||||||
|
t.Errorf("超限 PUT err = %v, want ErrObjectTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePARDefaultsName(t *testing.T) {
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
par, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
|
||||||
|
oci.CreatePARInput{ObjectName: "db/app.sql.gz", AccessType: "ObjectRead", ExpiresHours: 24})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePAR: %v", err)
|
||||||
|
}
|
||||||
|
if client.createdPAR.Name != "par-db-app.sql.gz" {
|
||||||
|
t.Errorf("default name = %q, want par-db-app.sql.gz", client.createdPAR.Name)
|
||||||
|
}
|
||||||
|
if par.FullURL == "" {
|
||||||
|
t.Errorf("FullURL empty, want populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -23,11 +24,17 @@ type OciConfigService struct {
|
|||||||
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
||||||
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
||||||
auditRaw *cache.Cache
|
auditRaw *cache.Cache
|
||||||
|
// bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。
|
||||||
|
bindWG sync.WaitGroup
|
||||||
|
bindStop chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOciConfigService 组装依赖。
|
// NewOciConfigService 组装依赖。
|
||||||
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
|
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
|
||||||
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
|
return &OciConfigService{
|
||||||
|
db: db, cipher: cipher, client: client,
|
||||||
|
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
|
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ func costItem(day string, amount float32, currency string) oci.CostItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
|
func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
|
||||||
|
// 日期取相对当前的近两天:任务查近 7 天,窗口外的行会被 Costs 过滤
|
||||||
|
day1 := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
|
||||||
|
day2 := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
client := &fakeClient{
|
client := &fakeClient{
|
||||||
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
|
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
|
||||||
costItems: []oci.CostItem{
|
costItems: []oci.CostItem{
|
||||||
costItem("2026-07-01", 5.2, "USD"),
|
costItem(day1, 5.2, "USD"),
|
||||||
costItem("2026-07-01", 1.4, "USD"),
|
costItem(day1, 1.4, "USD"),
|
||||||
costItem("2026-07-02", 6.3, "USD"),
|
costItem(day2, 6.3, "USD"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tasks, configs, db := newTaskEnv(t, client)
|
tasks, configs, db := newTaskEnv(t, client)
|
||||||
@@ -55,13 +58,13 @@ func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
|
|||||||
t.Errorf("log = %+v, want synced 1 skipped 1", entry)
|
t.Errorf("log = %+v, want synced 1 skipped 1", entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3})
|
assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3})
|
||||||
|
|
||||||
// 再次执行为覆盖更新,不产生重复行
|
// 再次执行为覆盖更新,不产生重复行
|
||||||
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
|
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
|
||||||
t.Fatalf("RunTaskNow again: %v", err)
|
t.Fatalf("RunTaskNow again: %v", err)
|
||||||
}
|
}
|
||||||
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3})
|
assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3})
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。
|
// assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReservedIPs 列出指定区间的保留公网 IP(compartmentID 空为生效 compartment)。
|
||||||
|
func (s *OciConfigService) ReservedIPs(ctx context.Context, id uint, region, compartmentID string) ([]oci.ReservedIP, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.client.ListReservedIPs(ctx, cred, region, compartmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReservedIP 在指定区间创建保留公网 IP。
|
||||||
|
func (s *OciConfigService) CreateReservedIP(ctx context.Context, id uint, region, compartmentID, displayName string) (oci.ReservedIP, error) {
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return oci.ReservedIP{}, err
|
||||||
|
}
|
||||||
|
return s.client.CreateReservedIP(ctx, cred, region, compartmentID, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignReservedIP 绑定保留 IP:vnicID 非空绑到该网卡主私有 IP,
|
||||||
|
// 否则绑实例主网卡(instanceID 为空表示解绑)。
|
||||||
|
func (s *OciConfigService) AssignReservedIP(ctx context.Context, id uint, region, publicIPID, instanceID, vnicID string) error {
|
||||||
|
if publicIPID == "" {
|
||||||
|
return fmt.Errorf("assign reserved ip: publicIpId is required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if vnicID != "" {
|
||||||
|
return s.client.AssignReservedIPToVnic(ctx, cred, region, publicIPID, vnicID)
|
||||||
|
}
|
||||||
|
return s.client.AssignReservedIP(ctx, cred, region, publicIPID, instanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReservedIP 删除保留公网 IP。
|
||||||
|
func (s *OciConfigService) DeleteReservedIP(ctx context.Context, id uint, region, publicIPID string) error {
|
||||||
|
if publicIPID == "" {
|
||||||
|
return fmt.Errorf("delete reserved ip: publicIpId is required")
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.client.DeleteReservedIP(ctx, cred, region, publicIPID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留 IP 后台绑定的轮询节奏与超时上限。
|
||||||
|
const (
|
||||||
|
reservedBindInterval = 10 * time.Second
|
||||||
|
reservedBindTimeout = 8 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// goBind 启动保留 IP 后台绑定 goroutine 并纳入 WaitGroup,供 Stop 等待收尾。
|
||||||
|
func (s *OciConfigService) goBind(fn func()) {
|
||||||
|
s.bindWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.bindWG.Done()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 通知在途的保留 IP 绑定 goroutine 提前退出并等待全部收尾;仅关服时调用一次。
|
||||||
|
func (s *OciConfigService) Stop() {
|
||||||
|
close(s.bindStop)
|
||||||
|
s.bindWG.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindReservedIPToVnicWhenReady 等次要 VNIC 就绪后绑定保留 IP;附加网卡的后台编排,
|
||||||
|
// 失败只记日志(附加请求本身已成功返回)。
|
||||||
|
func (s *OciConfigService) bindReservedIPToVnicWhenReady(cred oci.Credentials, region, instanceID, attachmentID, publicIPID string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), reservedBindTimeout)
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
vnics, err := s.client.ListInstanceVnics(ctx, cred, region, instanceID)
|
||||||
|
if err == nil {
|
||||||
|
if vnicID := readyVnicID(vnics, attachmentID); vnicID != "" {
|
||||||
|
if err := s.client.AssignReservedIPToVnic(ctx, cred, region, publicIPID, vnicID); err != nil {
|
||||||
|
log.Printf("[reserved-ip] bind %s to vnic %s failed: %v", publicIPID, vnicID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("[reserved-ip] bind %s timed out waiting for attachment %s", publicIPID, attachmentID)
|
||||||
|
return
|
||||||
|
case <-s.bindStop:
|
||||||
|
return
|
||||||
|
case <-time.After(reservedBindInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyVnicID 从网卡列表找出指定附加关系中已就绪的 VNIC。
|
||||||
|
func readyVnicID(vnics []oci.Vnic, attachmentID string) string {
|
||||||
|
for _, v := range vnics {
|
||||||
|
if v.AttachmentID == attachmentID && v.VnicID != "" && v.LifecycleState == "ATTACHED" {
|
||||||
|
return v.VnicID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindReservedIPWhenReady 等实例 RUNNING 后绑定保留 IP;创建实例的后台编排,
|
||||||
|
// 失败只记日志(创建请求本身已成功返回)。
|
||||||
|
func (s *OciConfigService) bindReservedIPWhenReady(cred oci.Credentials, region, instanceID, publicIPID string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), reservedBindTimeout)
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
inst, err := s.client.GetInstance(ctx, cred, region, instanceID)
|
||||||
|
if err == nil && inst.LifecycleState == "RUNNING" {
|
||||||
|
if err := s.client.AssignReservedIP(ctx, cred, region, publicIPID, instanceID); err != nil {
|
||||||
|
log.Printf("[reserved-ip] bind %s to instance %s failed: %v", publicIPID, instanceID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("[reserved-ip] bind %s timed out waiting for instance %s", publicIPID, instanceID)
|
||||||
|
return
|
||||||
|
case <-s.bindStop:
|
||||||
|
return
|
||||||
|
case <-time.After(reservedBindInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reservedIPStubClient struct {
|
||||||
|
*fakeClient
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ips []oci.ReservedIP
|
||||||
|
assigned map[string]string // publicIpId -> instanceId
|
||||||
|
vnicBound map[string]string // publicIpId -> vnicId
|
||||||
|
deleted []string
|
||||||
|
instState string // GetInstance 返回的状态
|
||||||
|
launched oci.CreateInstanceInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) ListReservedIPs(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.ReservedIP, error) {
|
||||||
|
return f.ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) CreateReservedIP(ctx context.Context, cred oci.Credentials, region, compartmentID, displayName string) (oci.ReservedIP, error) {
|
||||||
|
ip := oci.ReservedIP{ID: "ocid1.publicip..new", DisplayName: displayName, IPAddress: "155.248.0.10", LifecycleState: "AVAILABLE"}
|
||||||
|
f.ips = append(f.ips, ip)
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) AssignReservedIP(ctx context.Context, cred oci.Credentials, region, publicIPID, instanceID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.assigned == nil {
|
||||||
|
f.assigned = map[string]string{}
|
||||||
|
}
|
||||||
|
f.assigned[publicIPID] = instanceID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) AssignReservedIPToVnic(ctx context.Context, cred oci.Credentials, region, publicIPID, vnicID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.vnicBound == nil {
|
||||||
|
f.vnicBound = map[string]string{}
|
||||||
|
}
|
||||||
|
f.vnicBound[publicIPID] = vnicID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) DeleteReservedIP(ctx context.Context, cred oci.Credentials, region, publicIPID string) error {
|
||||||
|
f.deleted = append(f.deleted, publicIPID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
|
||||||
|
f.launched = in
|
||||||
|
return oci.Instance{ID: "ocid1.instance..new", LifecycleState: "PROVISIONING"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) GetInstance(ctx context.Context, cred oci.Credentials, region, instanceID string) (oci.Instance, error) {
|
||||||
|
return oci.Instance{ID: instanceID, LifecycleState: f.instState}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reservedIPStubClient) assignedTo(publicIPID string) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.assigned[publicIPID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReservedIPCrud(t *testing.T) {
|
||||||
|
client := &reservedIPStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := svc.CreateReservedIP(ctx, cfg.ID, "ap-tokyo-1", "", "ip-1"); err != nil {
|
||||||
|
t.Fatalf("CreateReservedIP: %v", err)
|
||||||
|
}
|
||||||
|
got, err := svc.ReservedIPs(ctx, cfg.ID, "ap-tokyo-1", "")
|
||||||
|
if err != nil || len(got) != 1 || got[0].DisplayName != "ip-1" {
|
||||||
|
t.Fatalf("ReservedIPs = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
// publicIpId 缺失被拒
|
||||||
|
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "", "inst", ""); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "publicIpId") {
|
||||||
|
t.Errorf("AssignReservedIP 空 id err = %v, want publicIpId required", err)
|
||||||
|
}
|
||||||
|
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new", "ocid1.instance..a", ""); err != nil {
|
||||||
|
t.Fatalf("AssignReservedIP: %v", err)
|
||||||
|
}
|
||||||
|
if got := client.assignedTo("ocid1.publicip..new"); got != "ocid1.instance..a" {
|
||||||
|
t.Errorf("assigned = %q, want instance a", got)
|
||||||
|
}
|
||||||
|
// vnicId 非空时路由到按网卡绑定
|
||||||
|
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new", "", "ocid1.vnic..v1"); err != nil {
|
||||||
|
t.Fatalf("AssignReservedIP vnic: %v", err)
|
||||||
|
}
|
||||||
|
if got := client.vnicBound["ocid1.publicip..new"]; got != "ocid1.vnic..v1" {
|
||||||
|
t.Errorf("vnicBound = %q, want vnic v1", got)
|
||||||
|
}
|
||||||
|
if err := svc.DeleteReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new"); err != nil {
|
||||||
|
t.Fatalf("DeleteReservedIP: %v", err)
|
||||||
|
}
|
||||||
|
if len(client.deleted) != 1 {
|
||||||
|
t.Errorf("deleted = %v, want 1 entry", client.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateInstanceWithReservedIP(t *testing.T) {
|
||||||
|
client := &reservedIPStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
instState: "RUNNING",
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
in := oci.CreateInstanceInput{
|
||||||
|
AvailabilityDomain: "AD-1",
|
||||||
|
DisplayName: "vm1",
|
||||||
|
Shape: "VM.Standard.A1.Flex",
|
||||||
|
ImageID: "ocid1.image..a",
|
||||||
|
SubnetID: "ocid1.subnet..s",
|
||||||
|
ReservedPublicIPID: "ocid1.publicip..r",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建 + 保留 IP 被拒
|
||||||
|
if _, _, err := svc.CreateInstances(ctx, cfg.ID, in, 2); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "single instance") {
|
||||||
|
t.Fatalf("批量+保留IP err = %v, want single instance", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instances, failures, err := svc.CreateInstances(ctx, cfg.ID, in, 1)
|
||||||
|
if err != nil || len(failures) != 0 || len(instances) != 1 {
|
||||||
|
t.Fatalf("CreateInstances = %+v, %v, %v", instances, failures, err)
|
||||||
|
}
|
||||||
|
// 后台 goroutine 轮询到 RUNNING 后完成绑定
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if client.assignedTo("ocid1.publicip..r") == "ocid1.instance..new" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("assigned = %q, want bind to new instance", client.assignedTo("ocid1.publicip..r"))
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint,
|
|||||||
rows := make([]model.CompartmentCache, 0, len(comps))
|
rows := make([]model.CompartmentCache, 0, len(comps))
|
||||||
for _, c := range comps {
|
for _, c := range comps {
|
||||||
rows = append(rows, model.CompartmentCache{
|
rows = append(rows, model.CompartmentCache{
|
||||||
OciConfigID: cfgID, OCID: c.ID, Name: c.Name, State: c.LifecycleState,
|
OciConfigID: cfgID, OCID: c.ID, Name: c.Name,
|
||||||
|
ParentOCID: c.ParentID, State: c.LifecycleState,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -191,16 +192,29 @@ func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]o
|
|||||||
Order("name").Find(&rows).Error; err != nil {
|
Order("name").Find(&rows).Error; err != nil {
|
||||||
return nil, fmt.Errorf("load compartment cache: %w", err)
|
return nil, fmt.Errorf("load compartment cache: %w", err)
|
||||||
}
|
}
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 || legacyCompartmentCache(rows) {
|
||||||
return s.refreshCompartmentCache(ctx, cfg)
|
return s.refreshCompartmentCache(ctx, cfg)
|
||||||
}
|
}
|
||||||
comps := make([]oci.Compartment, 0, len(rows))
|
comps := make([]oci.Compartment, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
comps = append(comps, oci.Compartment{ID: r.OCID, Name: r.Name, LifecycleState: r.State})
|
comps = append(comps, oci.Compartment{
|
||||||
|
ID: r.OCID, Name: r.Name, ParentID: r.ParentOCID, LifecycleState: r.State,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return comps, nil
|
return comps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyCompartmentCache 识别未存 parentId 的旧格式缓存:每个 compartment
|
||||||
|
// 必有父(至少是租户根),整组 parent 全空即旧数据,需实时刷新一次自愈。
|
||||||
|
func legacyCompartmentCache(rows []model.CompartmentCache) bool {
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.ParentOCID != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(rows) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。
|
// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。
|
||||||
func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) {
|
func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) {
|
||||||
cred, err := s.credentialsOf(cfg)
|
cred, err := s.credentialsOf(cfg)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func multiScopeClient() *fakeClient {
|
|||||||
{Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"},
|
{Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"},
|
||||||
},
|
},
|
||||||
compartments: []oci.Compartment{
|
compartments: []oci.Compartment{
|
||||||
{ID: "ocid1.compartment..a", Name: "instances", LifecycleState: "ACTIVE"},
|
{ID: "ocid1.compartment..a", Name: "instances", ParentID: "ocid1.tenancy..root", LifecycleState: "ACTIVE"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,9 @@ func TestCachedCompartments(t *testing.T) {
|
|||||||
if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" {
|
if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" {
|
||||||
t.Errorf("comps = %+v, want 缓存中的 1 项", comps)
|
t.Errorf("comps = %+v, want 缓存中的 1 项", comps)
|
||||||
}
|
}
|
||||||
|
if comps[0].ParentID != "ocid1.tenancy..root" {
|
||||||
|
t.Errorf("ParentID = %q, want 缓存透传 parentId", comps[0].ParentID)
|
||||||
|
}
|
||||||
|
|
||||||
in := trialImportInput()
|
in := trialImportInput()
|
||||||
in.Alias = "未开启"
|
in.Alias = "未开启"
|
||||||
@@ -170,6 +173,32 @@ func TestCachedCompartments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCachedCompartmentsHealsLegacyRows 旧版缓存(未存 parentId)应实时刷新自愈并回写。
|
||||||
|
func TestCachedCompartmentsHealsLegacyRows(t *testing.T) {
|
||||||
|
client := multiScopeClient()
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importMultiConfig(t, svc)
|
||||||
|
|
||||||
|
// 模拟旧版残留:抹掉缓存行的 parentId
|
||||||
|
if err := svc.db.Model(&model.CompartmentCache{}).Where("oci_config_id = ?", cfg.ID).
|
||||||
|
Update("ParentOCID", "").Error; err != nil {
|
||||||
|
t.Fatalf("清空 parentId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comps, err := svc.CachedCompartments(context.Background(), cfg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CachedCompartments: %v", err)
|
||||||
|
}
|
||||||
|
if len(comps) != 1 || comps[0].ParentID != "ocid1.tenancy..root" {
|
||||||
|
t.Errorf("comps = %+v, want 自愈刷新后带 parentId", comps)
|
||||||
|
}
|
||||||
|
var rows []model.CompartmentCache
|
||||||
|
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&rows)
|
||||||
|
if len(rows) != 1 || rows[0].ParentOCID != "ocid1.tenancy..root" {
|
||||||
|
t.Errorf("cache rows = %+v, want 缓存已回写 parentId", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegionSubscriptionsReconcilesCache(t *testing.T) {
|
func TestRegionSubscriptionsReconcilesCache(t *testing.T) {
|
||||||
client := multiScopeClient()
|
client := multiScopeClient()
|
||||||
svc := newTestService(t, client)
|
svc := newTestService(t, client)
|
||||||
|
|||||||
@@ -45,7 +45,28 @@ func (s *OciConfigService) Costs(ctx context.Context, id uint, q oci.CostQuery)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.SummarizeCosts(ctx, cred, q)
|
items, err := s.client.SummarizeCosts(ctx, cred, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return filterCostWindow(items, q.StartTime, q.EndTime), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterCostWindow 只保留落在请求窗口 [start, end) 内的行:Usage API 的
|
||||||
|
// HOURLY 粒度会把起点下扩到 UTC 日零点,返回窗口之外的整日数据。
|
||||||
|
func filterCostWindow(items []oci.CostItem, start, end time.Time) []oci.CostItem {
|
||||||
|
out := items[:0]
|
||||||
|
for _, it := range items {
|
||||||
|
if it.TimeStart == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts := it.TimeStart.UTC()
|
||||||
|
if ts.Before(start) || !ts.Before(end) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。
|
// applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。
|
||||||
@@ -73,8 +94,11 @@ func applyCostDefaults(q *oci.CostQuery) {
|
|||||||
|
|
||||||
func alignDown(t time.Time, granularity string) time.Time {
|
func alignDown(t time.Time, granularity string) time.Time {
|
||||||
t = t.UTC()
|
t = t.UTC()
|
||||||
if granularity == "MONTHLY" {
|
switch granularity {
|
||||||
|
case "MONTHLY":
|
||||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
case "HOURLY":
|
||||||
|
return t.Truncate(time.Hour)
|
||||||
}
|
}
|
||||||
return t.Truncate(24 * time.Hour)
|
return t.Truncate(24 * time.Hour)
|
||||||
}
|
}
|
||||||
@@ -84,8 +108,11 @@ func alignUp(t time.Time, granularity string) time.Time {
|
|||||||
if down.Equal(t.UTC()) {
|
if down.Equal(t.UTC()) {
|
||||||
return down
|
return down
|
||||||
}
|
}
|
||||||
if granularity == "MONTHLY" {
|
switch granularity {
|
||||||
|
case "MONTHLY":
|
||||||
return down.AddDate(0, 1, 0)
|
return down.AddDate(0, 1, 0)
|
||||||
|
case "HOURLY":
|
||||||
|
return down.Add(time.Hour)
|
||||||
}
|
}
|
||||||
return down.AddDate(0, 0, 1)
|
return down.AddDate(0, 0, 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,40 @@ func TestApplyCostDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterCostWindow(t *testing.T) {
|
||||||
|
hour := func(h int) *time.Time {
|
||||||
|
ts := time.Date(2026, 7, 20, h, 0, 0, 0, time.UTC)
|
||||||
|
return &ts
|
||||||
|
}
|
||||||
|
start := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
items []oci.CostItem
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "空输入", items: []oci.CostItem{}, want: 0},
|
||||||
|
{name: "窗口前的整日下扩数据被丢弃", items: []oci.CostItem{{TimeStart: hour(0)}, {TimeStart: hour(15)}}, want: 0},
|
||||||
|
{name: "起点边界含", items: []oci.CostItem{{TimeStart: hour(16)}}, want: 1},
|
||||||
|
{name: "终点边界不含", items: []oci.CostItem{{TimeStart: &end}}, want: 0},
|
||||||
|
{name: "TimeStart 缺失丢弃", items: []oci.CostItem{{TimeStart: nil}}, want: 0},
|
||||||
|
{name: "窗口内外混合只留窗口内", items: []oci.CostItem{{TimeStart: hour(2)}, {TimeStart: hour(16)}, {TimeStart: hour(23)}, {TimeStart: &end}}, want: 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterCostWindow(tt.items, start, end)
|
||||||
|
if len(got) != tt.want {
|
||||||
|
t.Errorf("filterCostWindow() kept %d rows, want %d", len(got), tt.want)
|
||||||
|
}
|
||||||
|
for _, it := range got {
|
||||||
|
if it.TimeStart.Before(start) || !it.TimeStart.Before(end) {
|
||||||
|
t.Errorf("row %v outside window [%v, %v)", it.TimeStart, start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateEmails(t *testing.T) {
|
func TestValidateEmails(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
@@ -12,17 +14,35 @@ import (
|
|||||||
type vnicStubClient struct {
|
type vnicStubClient struct {
|
||||||
*fakeClient
|
*fakeClient
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
vnics []oci.Vnic
|
vnics []oci.Vnic
|
||||||
attached oci.AttachVnicInput
|
attached oci.AttachVnicInput
|
||||||
attachInst string
|
attachInst string
|
||||||
detachedID string
|
detachedID string
|
||||||
ipv6Vnic string
|
ipv6Vnic string
|
||||||
|
boundVnic string // AssignReservedIPToVnic 记录
|
||||||
|
boundIP string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *vnicStubClient) ListInstanceVnics(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.Vnic, error) {
|
func (f *vnicStubClient) ListInstanceVnics(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.Vnic, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
return f.vnics, nil
|
return f.vnics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *vnicStubClient) AssignReservedIPToVnic(ctx context.Context, cred oci.Credentials, region, publicIPID, vnicID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.boundIP, f.boundVnic = publicIPID, vnicID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *vnicStubClient) bound() (string, string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.boundIP, f.boundVnic
|
||||||
|
}
|
||||||
|
|
||||||
func (f *vnicStubClient) AttachVnic(ctx context.Context, cred oci.Credentials, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
|
func (f *vnicStubClient) AttachVnic(ctx context.Context, cred oci.Credentials, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
|
||||||
f.attachInst, f.attached = instanceID, in
|
f.attachInst, f.attached = instanceID, in
|
||||||
return oci.Vnic{AttachmentID: "att-1", SubnetID: in.SubnetID, LifecycleState: "ATTACHING"}, nil
|
return oci.Vnic{AttachmentID: "att-1", SubnetID: in.SubnetID, LifecycleState: "ATTACHING"}, nil
|
||||||
@@ -73,3 +93,27 @@ func TestVnicManagement(t *testing.T) {
|
|||||||
t.Fatalf("AddVnicIpv6 = %q, %v (vnic %q)", addr, err, client.ipv6Vnic)
|
t.Fatalf("AddVnicIpv6 = %q, %v (vnic %q)", addr, err, client.ipv6Vnic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAttachVnicWithReservedIP(t *testing.T) {
|
||||||
|
client := &vnicStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
// 附加返回 att-1;列表里它已就绪,后台编排应绑定到对应 VNIC
|
||||||
|
vnics: []oci.Vnic{{AttachmentID: "att-1", VnicID: "ocid1.vnic.oc1..new", LifecycleState: "ATTACHED"}},
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
in := oci.AttachVnicInput{SubnetID: "ocid1.subnet.oc1..s", ReservedPublicIPID: "ocid1.publicip..r"}
|
||||||
|
if _, err := svc.AttachVnic(context.Background(), cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", in); err != nil {
|
||||||
|
t.Fatalf("AttachVnic: %v", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if ip, vnic := client.bound(); ip == "ocid1.publicip..r" && vnic == "ocid1.vnic.oc1..new" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
ip, vnic := client.bound()
|
||||||
|
t.Fatalf("bound = (%q, %q), want reserved ip bound to new vnic", ip, vnic)
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type ConsoleSession struct {
|
|||||||
type ConsoleService struct {
|
type ConsoleService struct {
|
||||||
configs *OciConfigService
|
configs *OciConfigService
|
||||||
pollInterval time.Duration // 清理残留连接的轮询间隔,测试注入缩短
|
pollInterval time.Duration // 清理残留连接的轮询间隔,测试注入缩短
|
||||||
|
sessionTTL time.Duration // 会话回收检查周期,测试注入缩短
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[string]*ConsoleSession
|
sessions map[string]*ConsoleSession
|
||||||
@@ -55,6 +56,7 @@ func NewConsoleService(configs *OciConfigService) *ConsoleService {
|
|||||||
return &ConsoleService{
|
return &ConsoleService{
|
||||||
configs: configs,
|
configs: configs,
|
||||||
pollInterval: 2 * time.Second,
|
pollInterval: 2 * time.Second,
|
||||||
|
sessionTTL: consoleSessionTTL,
|
||||||
sessions: map[string]*ConsoleSession{},
|
sessions: map[string]*ConsoleSession{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,11 +163,12 @@ func (s *ConsoleService) storeSession(cfgID uint, instanceID, region, typ, connI
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.sessions[sess.ID] = sess
|
s.sessions[sess.ID] = sess
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
time.AfterFunc(consoleSessionTTL, func() { s.expire(sess.ID) })
|
time.AfterFunc(s.sessionTTL, func() { s.expire(sess.ID) })
|
||||||
return sess
|
return sess
|
||||||
}
|
}
|
||||||
|
|
||||||
// expire TTL 到期回收:正在使用的会话跳过(连接断开后自然停止,无续期)。
|
// expire TTL 到期回收:正在使用的会话跳过并重挂下一轮检查,
|
||||||
|
// 连接断开后由后续轮次回收,避免会话与云端连接常驻到进程退出。
|
||||||
func (s *ConsoleService) expire(id string) {
|
func (s *ConsoleService) expire(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
sess, ok := s.sessions[id]
|
sess, ok := s.sessions[id]
|
||||||
@@ -173,6 +176,7 @@ func (s *ConsoleService) expire(id string) {
|
|||||||
sess.mu.Lock()
|
sess.mu.Lock()
|
||||||
if sess.inUse {
|
if sess.inUse {
|
||||||
ok = false
|
ok = false
|
||||||
|
time.AfterFunc(s.sessionTTL, func() { s.expire(id) })
|
||||||
} else {
|
} else {
|
||||||
delete(s.sessions, id)
|
delete(s.sessions, id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,3 +116,44 @@ func TestCreateConsoleSession(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleSessionExpire 验证过期回收:在用会话跳过本轮,断开后下一轮回收。
|
||||||
|
func TestConsoleSessionExpire(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inUse bool
|
||||||
|
wantAlive bool
|
||||||
|
}{
|
||||||
|
{name: "在用会话跳过回收", inUse: true, wantAlive: true},
|
||||||
|
{name: "空闲会话回收并删云端连接", inUse: false, wantAlive: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client := &consoleFakeClient{}
|
||||||
|
console, cfgID := newConsoleTestService(t, client)
|
||||||
|
sess, err := console.CreateSession(context.Background(), cfgID, "ocid1.instance.i", "", ConsoleTypeSerial)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
sess.MarkUse(tt.inUse)
|
||||||
|
console.expire(sess.ID)
|
||||||
|
if alive := console.Get(sess.ID) != nil; alive != tt.wantAlive {
|
||||||
|
t.Fatalf("session alive = %v, want %v", alive, tt.wantAlive)
|
||||||
|
}
|
||||||
|
if wantDel := !tt.wantAlive; (len(client.deleted) == 1) != wantDel {
|
||||||
|
t.Errorf("cloud connection deleted %v, want %v", client.deleted, wantDel)
|
||||||
|
}
|
||||||
|
if tt.inUse {
|
||||||
|
// 断开后下一轮回收(此前的缺陷:跳过后不再检查,会话常驻)
|
||||||
|
sess.MarkUse(false)
|
||||||
|
console.expire(sess.ID)
|
||||||
|
if console.Get(sess.ID) != nil {
|
||||||
|
t.Fatal("session still alive after idle expire round")
|
||||||
|
}
|
||||||
|
if len(client.deleted) != 1 {
|
||||||
|
t.Errorf("cloud connection not deleted after idle expire: %v", client.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user