From f40f2a20e8d97dd0bc82787a09cb831efbb56f81 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Tue, 21 Jul 2026 12:11:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=B9=E8=B1=A1=E5=AD=98?= =?UTF-8?q?=E5=82=A8/=E8=B4=A6=E5=8D=95/=E4=BF=9D=E7=95=99IP=E6=A8=A1?= =?UTF-8?q?=E5=9D=97,=E5=88=A0=E6=A1=B6=E5=B9=B6=E5=8F=91=E6=8F=90?= =?UTF-8?q?=E9=80=9F=E4=B8=8E=E6=88=90=E6=9C=AC=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/concurrency.md | 31 + .trellis/spec/backend/directory-structure.md | 4 +- .trellis/spec/backend/error-handling.md | 10 + .trellis/spec/backend/index.md | 4 +- .trellis/spec/backend/quality-guidelines.md | 11 + README.md | 2 +- cmd/server/main.go | 1 + docs/docs.go | 1544 +++++++++++++++++- docs/swagger.json | 1544 +++++++++++++++++- docs/swagger.yaml | 990 ++++++++++- internal/api/attachment.go | 29 +- internal/api/billing.go | 139 ++ internal/api/instance.go | 2 + internal/api/objectstorage.go | 450 +++++ internal/api/reservedip.go | 110 ++ internal/api/routes_oci.go | 34 + internal/api/tenant.go | 5 + internal/model/models.go | 1 + internal/oci/billing.go | 320 ++++ internal/oci/client.go | 41 +- internal/oci/cost.go | 51 +- internal/oci/instance.go | 5 + internal/oci/network.go | 4 +- internal/oci/objectstorage.go | 706 ++++++++ internal/oci/objectstorage_test.go | 29 + internal/oci/proxyhttp.go | 50 +- internal/oci/proxyhttp_test.go | 57 + internal/oci/publicip.go | 70 +- internal/oci/reservedip.go | 197 +++ internal/oci/vcndelete.go | 27 +- internal/oci/vnic.go | 6 + internal/service/attachment.go | 25 +- internal/service/billing.go | 72 + internal/service/billing_test.go | 109 ++ internal/service/instance.go | 7 + internal/service/objectstorage.go | 349 ++++ internal/service/objectstorage_test.go | 405 +++++ internal/service/ociconfig.go | 9 +- internal/service/reservedip.go | 137 ++ internal/service/reservedip_test.go | 150 ++ internal/service/scopecache.go | 20 +- internal/service/scopecache_test.go | 31 +- internal/service/usage.go | 10 +- internal/service/vnic_test.go | 44 + 44 files changed, 7788 insertions(+), 54 deletions(-) create mode 100644 internal/api/billing.go create mode 100644 internal/api/objectstorage.go create mode 100644 internal/api/reservedip.go create mode 100644 internal/oci/billing.go create mode 100644 internal/oci/objectstorage.go create mode 100644 internal/oci/objectstorage_test.go create mode 100644 internal/oci/reservedip.go create mode 100644 internal/service/billing.go create mode 100644 internal/service/billing_test.go create mode 100644 internal/service/objectstorage.go create mode 100644 internal/service/objectstorage_test.go create mode 100644 internal/service/reservedip.go create mode 100644 internal/service/reservedip_test.go diff --git a/.trellis/spec/backend/concurrency.md b/.trellis/spec/backend/concurrency.md index 80fbcfa..8c03410 100644 --- a/.trellis/spec/backend/concurrency.md +++ b/.trellis/spec/backend/concurrency.md @@ -27,3 +27,34 @@ go func() { - 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。 - cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前**。 - 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add` 时 `Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。 + +## HTTP 处理器不得同步执行长任务 + +**问题**:`POST /tasks/:id/run` 曾在处理器内同步跑完整个任务(AI 探测挂上模型验证后单次 24~90s),前端点击后长时间零反馈;且执行入口无并发防护,双击/与 cron 重叠会真跑两次。 + +**约定**(task.go `TriggerTask` 为范例): + +- 可能超过 1~2s 的执行,API 只做「异步触发」:校验存在 → 抢占执行权 → 挂 WaitGroup 的 goroutine 执行 → 立即 202;结果落任务日志/通知,由前端轮询呈现。 +- 在飞防重用 `map[uint]bool` + 专属 mutex(`beginRun`/`endRun`):手动重复触发返回哨兵错误(API 映射 409),cron 重叠触发静默跳过。执行权的获取必须在触发方同步完成,不能移进 goroutine(否则有双触发窗口)。 +- 后台执行 goroutine 一律纳入服务的 WaitGroup,`Stop()` 里 `cron.Stop()` 之后追加 `runWG.Wait()` 收尾。 + +## 上游 HTTP 超时:流式/长调用禁用 http.Client.Timeout 总超时 + +**问题**(2026-07 responses 直通 60s 断流):`http.Client.Timeout` 覆盖单次请求全生命周期(发起→响应头→**body 读完**)。OCI SDK 默认 60s(`common/client.go` defaultTimeout),对 SSE 流式(读 body 无上限)与 multi-agent/搜索类慢模型(>60s 才回响应头)必然掐断;且超时被 `switchable` 判为可重试,还会误伤渠道熔断计数。 + +**约定**(genai_responses.go 为范例): + +- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。 +- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。 +- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。 +- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。 + +## 出站连接复用与批量删除(2026-07 删桶超时复盘) + +**问题**:每个 OCI 操作都新建 SDK client,代理路径连带每次 new 一个 `http.Transport`——连接零复用,每请求付整条 TCP+SOCKS5+TLS 握手(经代理 3+ RTT ≈ 1s+);且对象存储每操作先远程取一次 namespace,单条 PAR 删除被放大到 ~2.3s,几千条串行删除撑爆 30 分钟 purge 窗口。用完即弃的 Transport 未设 `IdleConnTimeout`(零值=客户端永不关),空闲连接堆到远端 ~65s 超时才断,代理侧稳态挂着几十条连接。 + +**约定**: + +- 代理出站 `http.Client` 一律经 proxyhttp.go 的包级 `proxyClients` 缓存(按 ProxySpec 值复用),不得在调用点新建 per-request Transport;共享实例只可包装(dispatcher wrap),不得改写其字段。连接池参数集中在 `pooledTransport()`:`MaxIdleConnsPerHost` 须 ≥ 批量并发数。 +- 租户常量(对象存储 namespace 等)在 `RealClient` 用 `sync.Map` 进程内缓存(照 `limitDefs`/`shapes` 模式),不逐操作远程取。 +- 批量逐条调用统一走 service 层 `forEachConcurrently` + `bulkDeleteWorkers`(16);对应的 SDK 删除请求带 `bulkRetryPolicy`(429/5xx 退避),并发提速与限流保护成对出现,二者缺一不可。 diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md index 7486d54..3eab6fb 100644 --- a/.trellis/spec/backend/directory-structure.md +++ b/.trellis/spec/backend/directory-structure.md @@ -1,6 +1,6 @@ # 后端目录结构 -> Go 后端工程的目录职责与代码组织约定。 +> Go 后端工程(`oci-portal/`)的目录职责与代码组织约定。 ## 目录职责 @@ -30,3 +30,5 @@ oci-portal/ - `defer` 紧跟资源获取语句,成对管理释放。 - 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。 - 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。 +- 路由参数:资源 id 可能含 `/` 等 URL 保留字符时(如 OCI PAR id),一律经 query 传递而非路径参数 —— gin 路径段不匹配 `%2F`,会直接 404(2026-07 round6 教训)。 +- OSP Gateway(账单/发票,`internal/oci/billing.go`):服务只在租户主区域提供,每个请求都带 `ospHomeRegion` 且客户端 `SetRegion` 到主区域 —— 主区域公共名经测活 `HomeRegionKey` + `RegionByKey` 解析,勿用凭据 region 直连;SDK 金额是 `*float32`,输出前经 `money()` 四舍五入抹掉 float64 转换噪音;`PayInvoice` 的 `Email` 是 API 必填(付款回执),service 层校验后透传。 diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md index 9495d4a..75e9aa3 100644 --- a/.trellis/spec/backend/error-handling.md +++ b/.trellis/spec/backend/error-handling.md @@ -31,3 +31,13 @@ func sanitizeURLError(err error) error { ``` **预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。 + +## OCI 错误按语义分类,不要只看 HTTP 状态码 + +**问题**:OCI 同一状态码承载多种语义,按状态码一刀切会误判。已踩过的案例(GenAI 网关): + +- **404 双义**:`NotAuthorizedOrNotFound`(消息 "Authorization failed or requested resource not found")是**租户级**无权限/无策略;"Entity with key … not found" 是**模型级**——该模型 OCID 在此区域无按需供给。前者应定论渠道无配额,后者应剔除该模型换下一个候选。 +- **400 微调基座**:"Not allowed to call finetune base model …, use Endpoint: false" 表示模型在该区域仅作微调基座(dedicated 供给),同样是模型级不可用,不是请求参数错误。 +- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。 + +**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。 diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index 9a1396d..6e18dff 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -1,6 +1,6 @@ # Backend Development Guidelines(oci-portal 后端规范) -> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。 +> Go 后端(`oci-portal/`)编码规范入口。 --- @@ -19,7 +19,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE | [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 | | [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 | | [Testing](./testing.md) | table-driven 测试要求 | 已填 | -| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 | +| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 部分已填 | | [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 | | [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 | diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index d039158..2f2fd76 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -16,3 +16,14 @@ - Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()`。 - 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`。 - 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。 + +## JSON 联合类型与内容留档保真(2026-07 内容日志失真教训) + +- aiwire 里兼容多形态的联合类型(如 `RespInput` string|数组)**必须成对实现** + `UnmarshalJSON` 与 `MarshalJSON`:只写 Unmarshal 时,任何再序列化(内容日志、 + 测试快照)都会退化成 Go 字段名形态(`{"Text":"","Items":[...],"IsArray":true}`), + 排障者复制它复现会直接 400。参考 `aiwire/openai.go Content` 的保形写法, + 往返用 table-driven 测试锁定(`aiwire/responses_test.go`)。 +- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析 + 后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal` 对 + RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。 diff --git a/README.md b/README.md index 8a80aad..cf596db 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 | 能力域 | 覆盖范围 | | --- | --- | -| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 | +| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象 / PAR 直传分享)、限额与成本查询 | | **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 | | **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 | | **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 | diff --git a/cmd/server/main.go b/cmd/server/main.go index d1f62c2..ff9af2a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -110,6 +110,7 @@ func run() error { } ociClient := oci.NewCachedClient(oci.NewClient()) ociConfigs := service.NewOciConfigService(db, cipher, ociClient) + defer ociConfigs.Stop() settings := service.NewSettingService(db, cipher) settings.SetEnvPublicURL(cfg.PublicURL) if err := settings.ReloadSecurity(context.Background()); err != nil { diff --git a/docs/docs.go b/docs/docs.go index 1d9ab62..176fcbe 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1833,6 +1833,701 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/buckets": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "存储桶列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "区间 OCID,空为生效区间", + "name": "compartmentId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "创建存储桶", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.createBucketRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "更新存储桶(可见性/版本控制)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.updateBucketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "删除存储桶(非空桶后台清空后删除)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "200": { + "description": "queued=true 表示已转后台清空删除", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象列表(前缀模式分页)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "前缀(虚拟目录)", + "name": "prefix", + "in": "query" + }, + { + "type": "string", + "description": "上页 nextStartWith 游标", + "name": "startWith", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认 100", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ListObjectsResult" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "删除对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/content": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回", + "tags": [ + "对象存储" + ], + "summary": "读取对象内容(面板中转)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "对象原始内容", + "schema": { + "type": "string" + } + }, + "413": { + "description": "对象超出中转上限", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412", + "tags": [ + "对象存储" + ], + "summary": "保存对象内容(面板中转)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "etag=新 ETag", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "412": { + "description": "If-Match 不匹配,对象已被并发修改", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "413": { + "description": "请求体超出上限", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象元数据", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ObjectDetail" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "重命名对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体:region、source、newName", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "重命名成功" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "取回 Archive 对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体:region、object、hours(可下载时长,默认 24)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "202": { + "description": "取回已提交,约 1 小时后可下载" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/pars": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页", + "tags": [ + "对象存储" + ], + "summary": "临时链接列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "分页游标(上次响应的 nextPage,空取首页)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数,默认 100,上限 1000", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.parPage" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "签发临时链接(PAR)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.createPARRequest" + } + } + ], + "responses": { + "201": { + "description": "fullUrl 仅创建响应返回,请立即保存", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.PAR" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "all=1 时删除桶内全部 PAR 并返回 {\"deleted\": n};否则按 parId 删单条", + "tags": [ + "对象存储" + ], + "summary": "删除临时链接", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "PAR ID(可含 / 等字符,故经 query 传递)", + "name": "parId", + "in": "query" + }, + { + "type": "string", + "description": "为 1 时删除全部", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "204": { + "description": "删除成功,链接立即失效" + } + } + } + }, "/api/v1/oci-configs/{id}/cached-compartments": { "get": { "security": [ @@ -1984,6 +2679,36 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "起始时间 RFC3339,缺省为 endTime-30 天", + "name": "startTime", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339,缺省为当前时刻", + "name": "endTime", + "in": "query" + }, + { + "type": "string", + "description": "HOURLY / DAILY / MONTHLY,缺省 DAILY", + "name": "granularity", + "in": "query" + }, + { + "type": "string", + "description": "COST / USAGE,缺省 COST", + "name": "queryType", + "in": "query" + }, + { + "type": "string", + "description": "分组维度,单维或复合(如 service,skuName),缺省 service", + "name": "groupBy", + "in": "query" } ], "responses": { @@ -2675,10 +3400,11 @@ const docTemplate = `{ "BearerAuth": [] } ], + "description": "旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP", "tags": [ "计算" ], - "summary": "---- 实例 IP ----", + "summary": "更换实例主网卡临时公网 IP", "parameters": [ { "type": "integer", @@ -3173,6 +3899,177 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/invoices": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "自然年过滤(按开票时间);缺省全量", + "name": "year", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.Invoice" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/lines": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票费用明细", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.InvoiceLine" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/pay": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "支付发票", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + }, + { + "description": "回执邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.payInvoiceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/pdf": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票 PDF 下载", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票号(用作下载文件名)", + "name": "number", + "in": "query" + } + ], + "responses": { + "200": { + "description": "PDF 原文", + "schema": { + "type": "file" + } + } + } + } + }, "/api/v1/oci-configs/{id}/limits": { "get": { "security": [ @@ -3484,6 +4381,45 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/object-storage/namespace": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象存储 namespace", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/password-policies": { "get": { "security": [ @@ -3575,6 +4511,39 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/payment-methods": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "付款方式列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.PaymentMethod" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/region-subscriptions": { "get": { "security": [ @@ -3648,6 +4617,170 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/reserved-ips": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "保留 IP 列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域,空为主区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "区间 OCID,空为生效区间", + "name": "compartmentId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.ReservedIP" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "创建保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体:region、compartmentId、displayName", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ReservedIP" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/reserved-ips/{publicIpId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑", + "tags": [ + "网络" + ], + "summary": "绑定/解绑保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "保留 IP OCID", + "name": "publicIpId", + "in": "path", + "required": true + }, + { + "description": "请求体:region、instanceId、vnicId", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "绑定成功" + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "删除保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "保留 IP OCID", + "name": "publicIpId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "204": { + "description": "删除成功" + } + } + } + }, "/api/v1/oci-configs/{id}/saml-metadata": { "get": { "security": [ @@ -4906,6 +6039,53 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP", + "tags": [ + "计算" + ], + "summary": "更换 VNIC 临时公网 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "VNIC OCID", + "name": "vnicId", + "in": "path", + "required": true + }, + { + "description": "请求体:region", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.publicIpResponse" + } + } + } + } + }, "/api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses": { "post": { "security": [ @@ -6145,6 +7325,10 @@ const docTemplate = `{ "region": { "type": "string" }, + "reservedPublicIpId": { + "description": "ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。", + "type": "string" + }, "subnetId": { "type": "string" } @@ -6178,6 +7362,33 @@ const docTemplate = `{ } } }, + "internal_api.createBucketRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "compartmentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publicRead": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "storageTier": { + "description": "Standard / Archive", + "type": "string" + }, + "versioningOn": { + "type": "boolean" + } + } + }, "internal_api.createConsoleConnectionRequest": { "type": "object", "required": [ @@ -6290,6 +7501,10 @@ const docTemplate = `{ "region": { "type": "string" }, + "reservedPublicIpId": { + "description": "非空时不分配临时 IP,实例就绪后自动绑定该保留 IP", + "type": "string" + }, "rootPassword": { "type": "string" }, @@ -6325,6 +7540,30 @@ const docTemplate = `{ } } }, + "internal_api.createPARRequest": { + "type": "object", + "required": [ + "accessType" + ], + "properties": { + "accessType": { + "type": "string" + }, + "expiresHours": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "objectName": { + "description": "空 = 桶级", + "type": "string" + }, + "region": { + "type": "string" + } + } + }, "internal_api.createSecurityListRequest": { "type": "object", "required": [ @@ -6824,6 +8063,20 @@ const docTemplate = `{ } } }, + "internal_api.parPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.PAR" + } + }, + "nextPage": { + "type": "string" + } + } + }, "internal_api.passwordResponse": { "type": "object", "properties": { @@ -6832,6 +8085,17 @@ const docTemplate = `{ } } }, + "internal_api.payInvoiceRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "internal_api.publicIpResponse": { "type": "object", "properties": { @@ -6942,6 +8206,20 @@ const docTemplate = `{ } } }, + "internal_api.updateBucketRequest": { + "type": "object", + "properties": { + "publicRead": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "versioningOn": { + "type": "boolean" + } + } + }, "internal_api.updateInstanceRequest": { "type": "object", "properties": { @@ -8595,6 +9873,36 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.Bucket": { + "type": "object", + "properties": { + "approximateCount": { + "type": "integer" + }, + "approximateSize": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "storageTier": { + "type": "string" + }, + "timeCreated": { + "type": "string" + }, + "versioningOn": { + "type": "boolean" + }, + "visibility": { + "description": "NoPublicAccess / ObjectRead", + "type": "string" + } + } + }, "oci-portal_internal_oci.Compartment": { "type": "object", "properties": { @@ -8701,6 +10009,10 @@ const docTemplate = `{ "groupValue": { "type": "string" }, + "subValue": { + "description": "复合分组的第二维取值(如 service 下的 skuName)", + "type": "string" + }, "timeStart": { "type": "string" }, @@ -8891,6 +10203,79 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.Invoice": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "amountDue": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "internalId": { + "type": "string" + }, + "isPaid": { + "type": "boolean" + }, + "isPayable": { + "type": "boolean" + }, + "isPaymentFailed": { + "type": "boolean" + }, + "number": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timeDue": { + "type": "string" + }, + "timeInvoice": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.InvoiceLine": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "orderNo": { + "type": "string" + }, + "product": { + "type": "string" + }, + "quantity": { + "type": "number" + }, + "timeEnd": { + "type": "string" + }, + "timeStart": { + "type": "string" + }, + "total": { + "type": "number" + }, + "unitPrice": { + "type": "number" + } + } + }, "oci-portal_internal_oci.LimitService": { "type": "object", "properties": { @@ -8925,6 +10310,26 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.ListObjectsResult": { + "type": "object", + "properties": { + "nextStartWith": { + "type": "string" + }, + "objects": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.ObjectSummary" + } + }, + "prefixes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "oci-portal_internal_oci.NotificationRecipients": { "type": "object", "properties": { @@ -8939,6 +10344,87 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.ObjectDetail": { + "type": "object", + "properties": { + "archivalState": { + "type": "string" + }, + "contentMd5": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "etag": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storageTier": { + "type": "string" + }, + "timeModified": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.ObjectSummary": { + "type": "object", + "properties": { + "archivalState": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storageTier": { + "type": "string" + }, + "timeModified": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.PAR": { + "type": "object", + "properties": { + "accessType": { + "type": "string" + }, + "fullUrl": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "objectName": { + "type": "string" + }, + "timeCreated": { + "type": "string" + }, + "timeExpires": { + "type": "string" + } + } + }, "oci-portal_internal_oci.PasswordPolicyInfo": { "type": "object", "properties": { @@ -8965,6 +10451,36 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.PaymentMethod": { + "type": "object", + "properties": { + "billingAgreement": { + "type": "string" + }, + "cardType": { + "type": "string" + }, + "email": { + "type": "string" + }, + "lastDigits": { + "type": "string" + }, + "method": { + "description": "CREDIT_CARD / PAYPAL", + "type": "string" + }, + "nameOnCard": { + "type": "string" + }, + "payerName": { + "type": "string" + }, + "timeExpiration": { + "type": "string" + } + } + }, "oci-portal_internal_oci.PromotionInfo": { "type": "object", "properties": { @@ -9019,6 +10535,32 @@ const docTemplate = `{ } } }, + "oci-portal_internal_oci.ReservedIP": { + "type": "object", + "properties": { + "assignedInstanceId": { + "type": "string" + }, + "assignedInstanceName": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "lifecycleState": { + "type": "string" + }, + "timeCreated": { + "type": "string" + } + } + }, "oci-portal_internal_oci.SecurityList": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 9bddd13..294b593 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1826,6 +1826,701 @@ } } }, + "/api/v1/oci-configs/{id}/buckets": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "存储桶列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "区间 OCID,空为生效区间", + "name": "compartmentId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "创建存储桶", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.createBucketRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "更新存储桶(可见性/版本控制)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.updateBucketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.Bucket" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "删除存储桶(非空桶后台清空后删除)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "200": { + "description": "queued=true 表示已转后台清空删除", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象列表(前缀模式分页)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "前缀(虚拟目录)", + "name": "prefix", + "in": "query" + }, + { + "type": "string", + "description": "上页 nextStartWith 游标", + "name": "startWith", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认 100", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ListObjectsResult" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "删除对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/content": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回", + "tags": [ + "对象存储" + ], + "summary": "读取对象内容(面板中转)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "对象原始内容", + "schema": { + "type": "string" + } + }, + "413": { + "description": "对象超出中转上限", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412", + "tags": [ + "对象存储" + ], + "summary": "保存对象内容(面板中转)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "etag=新 ETag", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "412": { + "description": "If-Match 不匹配,对象已被并发修改", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "413": { + "description": "请求体超出上限", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象元数据", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "对象名(完整 key)", + "name": "object", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ObjectDetail" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "重命名对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体:region、source、newName", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "重命名成功" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "取回 Archive 对象", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体:region、object、hours(可下载时长,默认 24)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "202": { + "description": "取回已提交,约 1 小时后可下载" + } + } + } + }, + "/api/v1/oci-configs/{id}/buckets/{bucket}/pars": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页", + "tags": [ + "对象存储" + ], + "summary": "临时链接列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "分页游标(上次响应的 nextPage,空取首页)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数,默认 100,上限 1000", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.parPage" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "签发临时链接(PAR)", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.createPARRequest" + } + } + ], + "responses": { + "201": { + "description": "fullUrl 仅创建响应返回,请立即保存", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.PAR" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "all=1 时删除桶内全部 PAR 并返回 {\"deleted\": n};否则按 parId 删单条", + "tags": [ + "对象存储" + ], + "summary": "删除临时链接", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "桶名", + "name": "bucket", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "PAR ID(可含 / 等字符,故经 query 传递)", + "name": "parId", + "in": "query" + }, + { + "type": "string", + "description": "为 1 时删除全部", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "204": { + "description": "删除成功,链接立即失效" + } + } + } + }, "/api/v1/oci-configs/{id}/cached-compartments": { "get": { "security": [ @@ -1977,6 +2672,36 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "起始时间 RFC3339,缺省为 endTime-30 天", + "name": "startTime", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339,缺省为当前时刻", + "name": "endTime", + "in": "query" + }, + { + "type": "string", + "description": "HOURLY / DAILY / MONTHLY,缺省 DAILY", + "name": "granularity", + "in": "query" + }, + { + "type": "string", + "description": "COST / USAGE,缺省 COST", + "name": "queryType", + "in": "query" + }, + { + "type": "string", + "description": "分组维度,单维或复合(如 service,skuName),缺省 service", + "name": "groupBy", + "in": "query" } ], "responses": { @@ -2668,10 +3393,11 @@ "BearerAuth": [] } ], + "description": "旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP", "tags": [ "计算" ], - "summary": "---- 实例 IP ----", + "summary": "更换实例主网卡临时公网 IP", "parameters": [ { "type": "integer", @@ -3166,6 +3892,177 @@ } } }, + "/api/v1/oci-configs/{id}/invoices": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "自然年过滤(按开票时间);缺省全量", + "name": "year", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.Invoice" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/lines": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票费用明细", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.InvoiceLine" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/pay": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "支付发票", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + }, + { + "description": "回执邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.payInvoiceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/oci-configs/{id}/invoices/{internalId}/pdf": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "发票 PDF 下载", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票内部 ID", + "name": "internalId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发票号(用作下载文件名)", + "name": "number", + "in": "query" + } + ], + "responses": { + "200": { + "description": "PDF 原文", + "schema": { + "type": "file" + } + } + } + } + }, "/api/v1/oci-configs/{id}/limits": { "get": { "security": [ @@ -3477,6 +4374,45 @@ } } }, + "/api/v1/oci-configs/{id}/object-storage/namespace": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "对象存储" + ], + "summary": "对象存储 namespace", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/password-policies": { "get": { "security": [ @@ -3568,6 +4504,39 @@ } } }, + "/api/v1/oci-configs/{id}/payment-methods": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "账单" + ], + "summary": "付款方式列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.PaymentMethod" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/region-subscriptions": { "get": { "security": [ @@ -3641,6 +4610,170 @@ } } }, + "/api/v1/oci-configs/{id}/reserved-ips": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "保留 IP 列表", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域,空为主区域", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "区间 OCID,空为生效区间", + "name": "compartmentId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.ReservedIP" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "创建保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体:region、compartmentId、displayName", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/oci-portal_internal_oci.ReservedIP" + } + } + } + } + }, + "/api/v1/oci-configs/{id}/reserved-ips/{publicIpId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑", + "tags": [ + "网络" + ], + "summary": "绑定/解绑保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "保留 IP OCID", + "name": "publicIpId", + "in": "path", + "required": true + }, + { + "description": "请求体:region、instanceId、vnicId", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "绑定成功" + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "网络" + ], + "summary": "删除保留 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "保留 IP OCID", + "name": "publicIpId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "区域", + "name": "region", + "in": "query" + } + ], + "responses": { + "204": { + "description": "删除成功" + } + } + } + }, "/api/v1/oci-configs/{id}/saml-metadata": { "get": { "security": [ @@ -4899,6 +6032,53 @@ } } }, + "/api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP", + "tags": [ + "计算" + ], + "summary": "更换 VNIC 临时公网 IP", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "VNIC OCID", + "name": "vnicId", + "in": "path", + "required": true + }, + { + "description": "请求体:region", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.publicIpResponse" + } + } + } + } + }, "/api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses": { "post": { "security": [ @@ -6138,6 +7318,10 @@ "region": { "type": "string" }, + "reservedPublicIpId": { + "description": "ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。", + "type": "string" + }, "subnetId": { "type": "string" } @@ -6171,6 +7355,33 @@ } } }, + "internal_api.createBucketRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "compartmentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publicRead": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "storageTier": { + "description": "Standard / Archive", + "type": "string" + }, + "versioningOn": { + "type": "boolean" + } + } + }, "internal_api.createConsoleConnectionRequest": { "type": "object", "required": [ @@ -6283,6 +7494,10 @@ "region": { "type": "string" }, + "reservedPublicIpId": { + "description": "非空时不分配临时 IP,实例就绪后自动绑定该保留 IP", + "type": "string" + }, "rootPassword": { "type": "string" }, @@ -6318,6 +7533,30 @@ } } }, + "internal_api.createPARRequest": { + "type": "object", + "required": [ + "accessType" + ], + "properties": { + "accessType": { + "type": "string" + }, + "expiresHours": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "objectName": { + "description": "空 = 桶级", + "type": "string" + }, + "region": { + "type": "string" + } + } + }, "internal_api.createSecurityListRequest": { "type": "object", "required": [ @@ -6817,6 +8056,20 @@ } } }, + "internal_api.parPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.PAR" + } + }, + "nextPage": { + "type": "string" + } + } + }, "internal_api.passwordResponse": { "type": "object", "properties": { @@ -6825,6 +8078,17 @@ } } }, + "internal_api.payInvoiceRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "internal_api.publicIpResponse": { "type": "object", "properties": { @@ -6935,6 +8199,20 @@ } } }, + "internal_api.updateBucketRequest": { + "type": "object", + "properties": { + "publicRead": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "versioningOn": { + "type": "boolean" + } + } + }, "internal_api.updateInstanceRequest": { "type": "object", "properties": { @@ -8588,6 +9866,36 @@ } } }, + "oci-portal_internal_oci.Bucket": { + "type": "object", + "properties": { + "approximateCount": { + "type": "integer" + }, + "approximateSize": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "storageTier": { + "type": "string" + }, + "timeCreated": { + "type": "string" + }, + "versioningOn": { + "type": "boolean" + }, + "visibility": { + "description": "NoPublicAccess / ObjectRead", + "type": "string" + } + } + }, "oci-portal_internal_oci.Compartment": { "type": "object", "properties": { @@ -8694,6 +10002,10 @@ "groupValue": { "type": "string" }, + "subValue": { + "description": "复合分组的第二维取值(如 service 下的 skuName)", + "type": "string" + }, "timeStart": { "type": "string" }, @@ -8884,6 +10196,79 @@ } } }, + "oci-portal_internal_oci.Invoice": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "amountDue": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "internalId": { + "type": "string" + }, + "isPaid": { + "type": "boolean" + }, + "isPayable": { + "type": "boolean" + }, + "isPaymentFailed": { + "type": "boolean" + }, + "number": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timeDue": { + "type": "string" + }, + "timeInvoice": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.InvoiceLine": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "orderNo": { + "type": "string" + }, + "product": { + "type": "string" + }, + "quantity": { + "type": "number" + }, + "timeEnd": { + "type": "string" + }, + "timeStart": { + "type": "string" + }, + "total": { + "type": "number" + }, + "unitPrice": { + "type": "number" + } + } + }, "oci-portal_internal_oci.LimitService": { "type": "object", "properties": { @@ -8918,6 +10303,26 @@ } } }, + "oci-portal_internal_oci.ListObjectsResult": { + "type": "object", + "properties": { + "nextStartWith": { + "type": "string" + }, + "objects": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_oci.ObjectSummary" + } + }, + "prefixes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "oci-portal_internal_oci.NotificationRecipients": { "type": "object", "properties": { @@ -8932,6 +10337,87 @@ } } }, + "oci-portal_internal_oci.ObjectDetail": { + "type": "object", + "properties": { + "archivalState": { + "type": "string" + }, + "contentMd5": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "etag": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storageTier": { + "type": "string" + }, + "timeModified": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.ObjectSummary": { + "type": "object", + "properties": { + "archivalState": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storageTier": { + "type": "string" + }, + "timeModified": { + "type": "string" + } + } + }, + "oci-portal_internal_oci.PAR": { + "type": "object", + "properties": { + "accessType": { + "type": "string" + }, + "fullUrl": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "objectName": { + "type": "string" + }, + "timeCreated": { + "type": "string" + }, + "timeExpires": { + "type": "string" + } + } + }, "oci-portal_internal_oci.PasswordPolicyInfo": { "type": "object", "properties": { @@ -8958,6 +10444,36 @@ } } }, + "oci-portal_internal_oci.PaymentMethod": { + "type": "object", + "properties": { + "billingAgreement": { + "type": "string" + }, + "cardType": { + "type": "string" + }, + "email": { + "type": "string" + }, + "lastDigits": { + "type": "string" + }, + "method": { + "description": "CREDIT_CARD / PAYPAL", + "type": "string" + }, + "nameOnCard": { + "type": "string" + }, + "payerName": { + "type": "string" + }, + "timeExpiration": { + "type": "string" + } + } + }, "oci-portal_internal_oci.PromotionInfo": { "type": "object", "properties": { @@ -9012,6 +10528,32 @@ } } }, + "oci-portal_internal_oci.ReservedIP": { + "type": "object", + "properties": { + "assignedInstanceId": { + "type": "string" + }, + "assignedInstanceName": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "lifecycleState": { + "type": "string" + }, + "timeCreated": { + "type": "string" + } + } + }, "oci-portal_internal_oci.SecurityList": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 886853a..b97ef3e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -93,6 +93,9 @@ definitions: type: string region: type: string + reservedPublicIpId: + description: ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。 + type: string subnetId: type: string required: @@ -116,6 +119,24 @@ definitions: config: $ref: '#/definitions/oci-portal_internal_model.OciConfig' type: object + internal_api.createBucketRequest: + properties: + compartmentId: + type: string + name: + type: string + publicRead: + type: boolean + region: + type: string + storageTier: + description: Standard / Archive + type: string + versioningOn: + type: boolean + required: + - name + type: object internal_api.createConsoleConnectionRequest: properties: region: @@ -189,6 +210,9 @@ definitions: type: number region: type: string + reservedPublicIpId: + description: 非空时不分配临时 IP,实例就绪后自动绑定该保留 IP + type: string rootPassword: type: string shape: @@ -215,6 +239,22 @@ definitions: $ref: '#/definitions/oci-portal_internal_oci.Instance' type: array type: object + internal_api.createPARRequest: + properties: + accessType: + type: string + expiresHours: + type: integer + name: + type: string + objectName: + description: 空 = 桶级 + type: string + region: + type: string + required: + - accessType + type: object internal_api.createSecurityListRequest: properties: displayName: @@ -544,11 +584,27 @@ definitions: total: type: integer type: object + internal_api.parPage: + properties: + items: + items: + $ref: '#/definitions/oci-portal_internal_oci.PAR' + type: array + nextPage: + type: string + type: object internal_api.passwordResponse: properties: password: type: string type: object + internal_api.payInvoiceRequest: + properties: + email: + type: string + required: + - email + type: object internal_api.publicIpResponse: properties: publicIp: @@ -619,6 +675,15 @@ definitions: vpusPerGB: type: integer type: object + internal_api.updateBucketRequest: + properties: + publicRead: + type: boolean + region: + type: string + versioningOn: + type: boolean + type: object internal_api.updateInstanceRequest: properties: displayName: @@ -1714,6 +1779,26 @@ definitions: lifecycleState: type: string type: object + oci-portal_internal_oci.Bucket: + properties: + approximateCount: + type: integer + approximateSize: + type: integer + name: + type: string + namespace: + type: string + storageTier: + type: string + timeCreated: + type: string + versioningOn: + type: boolean + visibility: + description: NoPublicAccess / ObjectRead + type: string + type: object oci-portal_internal_oci.Compartment: properties: description: @@ -1787,6 +1872,9 @@ definitions: type: string groupValue: type: string + subValue: + description: 复合分组的第二维取值(如 service 下的 skuName) + type: string timeStart: type: string unit: @@ -1912,6 +2000,54 @@ definitions: $ref: '#/definitions/oci-portal_internal_oci.VnicTraffic' type: array type: object + oci-portal_internal_oci.Invoice: + properties: + amount: + type: number + amountDue: + type: number + currency: + type: string + id: + type: string + internalId: + type: string + isPaid: + type: boolean + isPayable: + type: boolean + isPaymentFailed: + type: boolean + number: + type: string + status: + type: string + timeDue: + type: string + timeInvoice: + type: string + type: + type: string + type: object + oci-portal_internal_oci.InvoiceLine: + properties: + currency: + type: string + orderNo: + type: string + product: + type: string + quantity: + type: number + timeEnd: + type: string + timeStart: + type: string + total: + type: number + unitPrice: + type: number + type: object oci-portal_internal_oci.LimitService: properties: description: @@ -1934,6 +2070,19 @@ definitions: value: type: integer type: object + oci-portal_internal_oci.ListObjectsResult: + properties: + nextStartWith: + type: string + objects: + items: + $ref: '#/definitions/oci-portal_internal_oci.ObjectSummary' + type: array + prefixes: + items: + type: string + type: array + type: object oci-portal_internal_oci.NotificationRecipients: properties: recipients: @@ -1943,6 +2092,59 @@ definitions: testModeEnabled: type: boolean type: object + oci-portal_internal_oci.ObjectDetail: + properties: + archivalState: + type: string + contentMd5: + type: string + contentType: + type: string + etag: + type: string + metadata: + additionalProperties: + type: string + type: object + name: + type: string + size: + type: integer + storageTier: + type: string + timeModified: + type: string + type: object + oci-portal_internal_oci.ObjectSummary: + properties: + archivalState: + type: string + name: + type: string + size: + type: integer + storageTier: + type: string + timeModified: + type: string + type: object + oci-portal_internal_oci.PAR: + properties: + accessType: + type: string + fullUrl: + type: string + id: + type: string + name: + type: string + objectName: + type: string + timeCreated: + type: string + timeExpires: + type: string + type: object oci-portal_internal_oci.PasswordPolicyInfo: properties: id: @@ -1960,6 +2162,26 @@ definitions: priority: type: integer type: object + oci-portal_internal_oci.PaymentMethod: + properties: + billingAgreement: + type: string + cardType: + type: string + email: + type: string + lastDigits: + type: string + method: + description: CREDIT_CARD / PAYPAL + type: string + nameOnCard: + type: string + payerName: + type: string + timeExpiration: + type: string + type: object oci-portal_internal_oci.PromotionInfo: properties: amount: @@ -1995,6 +2217,23 @@ definitions: state: type: string type: object + oci-portal_internal_oci.ReservedIP: + properties: + assignedInstanceId: + type: string + assignedInstanceName: + type: string + displayName: + type: string + id: + type: string + ipAddress: + type: string + lifecycleState: + type: string + timeCreated: + type: string + type: object oci-portal_internal_oci.SecurityList: properties: displayName: @@ -3815,6 +4054,450 @@ paths: summary: 更新引导卷 tags: - 存储 + /api/v1/oci-configs/{id}/buckets: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 区域 + in: query + name: region + type: string + - description: 区间 OCID,空为生效区间 + in: query + name: compartmentId + type: string + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/oci-portal_internal_oci.Bucket' + type: array + security: + - BearerAuth: [] + summary: 存储桶列表 + tags: + - 对象存储 + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 请求体 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.createBucketRequest' + responses: + "201": + description: Created + schema: + $ref: '#/definitions/oci-portal_internal_oci.Bucket' + security: + - BearerAuth: [] + summary: 创建存储桶 + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}: + delete: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + responses: + "200": + description: queued=true 表示已转后台清空删除 + schema: + additionalProperties: + type: boolean + type: object + security: + - BearerAuth: [] + summary: 删除存储桶(非空桶后台清空后删除) + tags: + - 对象存储 + put: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 请求体 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.updateBucketRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/oci-portal_internal_oci.Bucket' + security: + - BearerAuth: [] + summary: 更新存储桶(可见性/版本控制) + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/objects: + delete: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 对象名(完整 key) + in: query + name: object + required: true + type: string + responses: + "204": + description: 删除成功 + security: + - BearerAuth: [] + summary: 删除对象 + tags: + - 对象存储 + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 前缀(虚拟目录) + in: query + name: prefix + type: string + - description: 上页 nextStartWith 游标 + in: query + name: startWith + type: string + - description: 每页数量,默认 100 + in: query + name: limit + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/oci-portal_internal_oci.ListObjectsResult' + security: + - BearerAuth: [] + summary: 对象列表(前缀模式分页) + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content: + get: + description: 预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 对象名(完整 key) + in: query + name: object + required: true + type: string + responses: + "200": + description: 对象原始内容 + schema: + type: string + "413": + description: 对象超出中转上限 + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 读取对象内容(面板中转) + tags: + - 对象存储 + put: + description: 文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 对象名(完整 key) + in: query + name: object + required: true + type: string + responses: + "200": + description: etag=新 ETag + schema: + additionalProperties: + type: string + type: object + "412": + description: If-Match 不匹配,对象已被并发修改 + schema: + additionalProperties: + type: string + type: object + "413": + description: 请求体超出上限 + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 保存对象内容(面板中转) + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 对象名(完整 key) + in: query + name: object + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/oci-portal_internal_oci.ObjectDetail' + security: + - BearerAuth: [] + summary: 对象元数据 + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename: + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 请求体:region、source、newName + in: body + name: body + required: true + schema: + type: object + responses: + "204": + description: 重命名成功 + security: + - BearerAuth: [] + summary: 重命名对象 + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore: + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 请求体:region、object、hours(可下载时长,默认 24) + in: body + name: body + required: true + schema: + type: object + responses: + "202": + description: 取回已提交,约 1 小时后可下载 + security: + - BearerAuth: [] + summary: 取回 Archive 对象 + tags: + - 对象存储 + /api/v1/oci-configs/{id}/buckets/{bucket}/pars: + delete: + description: 'all=1 时删除桶内全部 PAR 并返回 {"deleted": n};否则按 parId 删单条' + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: PAR ID(可含 / 等字符,故经 query 传递) + in: query + name: parId + type: string + - description: 为 1 时删除全部 + in: query + name: all + type: string + - description: 区域 + in: query + name: region + type: string + responses: + "204": + description: 删除成功,链接立即失效 + security: + - BearerAuth: [] + summary: 删除临时链接 + tags: + - 对象存储 + get: + description: 游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 区域 + in: query + name: region + type: string + - description: 分页游标(上次响应的 nextPage,空取首页) + in: query + name: page + type: string + - description: 每页条数,默认 100,上限 1000 + in: query + name: limit + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.parPage' + security: + - BearerAuth: [] + summary: 临时链接列表 + tags: + - 对象存储 + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 桶名 + in: path + name: bucket + required: true + type: string + - description: 请求体 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.createPARRequest' + responses: + "201": + description: fullUrl 仅创建响应返回,请立即保存 + schema: + $ref: '#/definitions/oci-portal_internal_oci.PAR' + security: + - BearerAuth: [] + summary: 签发临时链接(PAR) + tags: + - 对象存储 /api/v1/oci-configs/{id}/cached-compartments: get: parameters: @@ -3904,6 +4587,26 @@ paths: name: id required: true type: integer + - description: 起始时间 RFC3339,缺省为 endTime-30 天 + in: query + name: startTime + type: string + - description: 结束时间 RFC3339,缺省为当前时刻 + in: query + name: endTime + type: string + - description: HOURLY / DAILY / MONTHLY,缺省 DAILY + in: query + name: granularity + type: string + - description: COST / USAGE,缺省 COST + in: query + name: queryType + type: string + - description: 分组维度,单维或复合(如 service,skuName),缺省 service + in: query + name: groupBy + type: string responses: "200": description: OK @@ -4335,6 +5038,7 @@ paths: - 存储 /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip: post: + description: 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP parameters: - description: 配置 ID in: path @@ -4359,7 +5063,7 @@ paths: $ref: '#/definitions/internal_api.publicIpResponse' security: - BearerAuth: [] - summary: '---- 实例 IP ----' + summary: 更换实例主网卡临时公网 IP tags: - 计算 /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections: @@ -4651,6 +5355,113 @@ paths: summary: 附加块存储卷 tags: - 存储 + /api/v1/oci-configs/{id}/invoices: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 自然年过滤(按开票时间);缺省全量 + in: query + name: year + type: integer + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/oci-portal_internal_oci.Invoice' + type: array + security: + - BearerAuth: [] + summary: 发票列表 + tags: + - 账单 + /api/v1/oci-configs/{id}/invoices/{internalId}/lines: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 发票内部 ID + in: path + name: internalId + required: true + type: string + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/oci-portal_internal_oci.InvoiceLine' + type: array + security: + - BearerAuth: [] + summary: 发票费用明细 + tags: + - 账单 + /api/v1/oci-configs/{id}/invoices/{internalId}/pay: + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 发票内部 ID + in: path + name: internalId + required: true + type: string + - description: 回执邮箱 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.payInvoiceRequest' + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 支付发票 + tags: + - 账单 + /api/v1/oci-configs/{id}/invoices/{internalId}/pdf: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 发票内部 ID + in: path + name: internalId + required: true + type: string + - description: 发票号(用作下载文件名) + in: query + name: number + type: string + responses: + "200": + description: PDF 原文 + schema: + type: file + security: + - BearerAuth: [] + summary: 发票 PDF 下载 + tags: + - 账单 /api/v1/oci-configs/{id}/limits: get: parameters: @@ -4840,6 +5651,30 @@ paths: summary: 更新通知收件人 tags: - 租户 IAM + /api/v1/oci-configs/{id}/object-storage/namespace: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 区域 + in: query + name: region + type: string + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 对象存储 namespace + tags: + - 对象存储 /api/v1/oci-configs/{id}/password-policies: get: parameters: @@ -4897,6 +5732,26 @@ paths: summary: 更新密码策略 tags: - 租户 IAM + /api/v1/oci-configs/{id}/payment-methods: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/oci-portal_internal_oci.PaymentMethod' + type: array + security: + - BearerAuth: [] + summary: 付款方式列表 + tags: + - 账单 /api/v1/oci-configs/{id}/region-subscriptions: get: parameters: @@ -4942,6 +5797,109 @@ paths: summary: 订阅新区域 tags: - 租户配置 + /api/v1/oci-configs/{id}/reserved-ips: + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 区域,空为主区域 + in: query + name: region + type: string + - description: 区间 OCID,空为生效区间 + in: query + name: compartmentId + type: string + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/oci-portal_internal_oci.ReservedIP' + type: array + security: + - BearerAuth: [] + summary: 保留 IP 列表 + tags: + - 网络 + post: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 请求体:region、compartmentId、displayName + in: body + name: body + required: true + schema: + type: object + responses: + "201": + description: Created + schema: + $ref: '#/definitions/oci-portal_internal_oci.ReservedIP' + security: + - BearerAuth: [] + summary: 创建保留 IP + tags: + - 网络 + /api/v1/oci-configs/{id}/reserved-ips/{publicIpId}: + delete: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 保留 IP OCID + in: path + name: publicIpId + required: true + type: string + - description: 区域 + in: query + name: region + type: string + responses: + "204": + description: 删除成功 + security: + - BearerAuth: [] + summary: 删除保留 IP + tags: + - 网络 + put: + description: vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 保留 IP OCID + in: path + name: publicIpId + required: true + type: string + - description: 请求体:region、instanceId、vnicId + in: body + name: body + required: true + schema: + type: object + responses: + "204": + description: 绑定成功 + security: + - BearerAuth: [] + summary: 绑定/解绑保留 IP + tags: + - 网络 /api/v1/oci-configs/{id}/saml-metadata: get: parameters: @@ -5725,6 +6683,36 @@ paths: summary: 分离 VNIC tags: - 计算 + /api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip: + post: + description: 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: VNIC OCID + in: path + name: vnicId + required: true + type: string + - description: 请求体:region + in: body + name: body + required: true + schema: + type: object + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.publicIpResponse' + security: + - BearerAuth: [] + summary: 更换 VNIC 临时公网 IP + tags: + - 计算 /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses: post: parameters: diff --git a/internal/api/attachment.go b/internal/api/attachment.go index b59e730..f4cb835 100644 --- a/internal/api/attachment.go +++ b/internal/api/attachment.go @@ -10,7 +10,8 @@ import ( // ---- 实例 IP ---- -// @Summary ---- 实例 IP ---- +// @Summary 更换实例主网卡临时公网 IP +// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP // @Tags 计算 // @Param id path int true "配置 ID" // @Param instanceId path string true "instanceId" @@ -35,6 +36,32 @@ func (h *ociConfigHandler) changePublicIP(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"publicIp": ip}) } +// @Summary 更换 VNIC 临时公网 IP +// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP +// @Tags 计算 +// @Param id path int true "配置 ID" +// @Param vnicId path string true "VNIC OCID" +// @Param body body object true "请求体:region" +// @Success 200 {object} publicIpResponse +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip [post] +func (h *ociConfigHandler) changeVnicPublicIP(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req struct { + Region string `json:"region"` + } + _ = c.ShouldBindJSON(&req) + ip, err := h.svc.ChangeVnicPublicIP(c.Request.Context(), id, req.Region, c.Param("vnicId")) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"publicIp": ip}) +} + // @Summary 实例添加 IPv6 地址 // @Tags 计算 // @Param id path int true "配置 ID" diff --git a/internal/api/billing.go b/internal/api/billing.go new file mode 100644 index 0000000..c00f52a --- /dev/null +++ b/internal/api/billing.go @@ -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) +} diff --git a/internal/api/instance.go b/internal/api/instance.go index d11334e..a723fdf 100644 --- a/internal/api/instance.go +++ b/internal/api/instance.go @@ -44,6 +44,7 @@ type createInstanceRequest struct { BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"` SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网 AssignPublicIP bool `json:"assignPublicIp"` + ReservedPublicIPID string `json:"reservedPublicIpId"` // 非空时不分配临时 IP,实例就绪后自动绑定该保留 IP AssignIpv6 bool `json:"assignIpv6"` SSHPublicKey string `json:"sshPublicKey"` RootPassword string `json:"rootPassword"` @@ -118,6 +119,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) { BootVolumeVpusPerGB: req.BootVolumeVpusPerGB, SubnetID: req.SubnetID, AssignPublicIP: req.AssignPublicIP, + ReservedPublicIPID: req.ReservedPublicIPID, AssignIpv6: req.AssignIpv6, SSHPublicKey: req.SSHPublicKey, RootPassword: req.RootPassword, diff --git a/internal/api/objectstorage.go b/internal/api/objectstorage.go new file mode 100644 index 0000000..0e963a7 --- /dev/null +++ b/internal/api/objectstorage.go @@ -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) +} diff --git a/internal/api/reservedip.go b/internal/api/reservedip.go new file mode 100644 index 0000000..a12fbbd --- /dev/null +++ b/internal/api/reservedip.go @@ -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) +} diff --git a/internal/api/routes_oci.go b/internal/api/routes_oci.go index 421fdf1..6291e4a 100644 --- a/internal/api/routes_oci.go +++ b/internal/api/routes_oci.go @@ -16,6 +16,7 @@ func registerOci(secured *gin.RouterGroup, ociConfigs *service.OciConfigService) registerOciCompute(secured, h) registerOciNetwork(secured, h) registerOciStorage(secured, h) + registerOciObjectStorage(secured, h) registerOciCost(secured, h) registerOciTenantIAM(secured, h) } @@ -37,6 +38,13 @@ func registerOciConfig(g *gin.RouterGroup, h *ociConfigHandler) { g.GET("/oci-configs/:id/limits/services", h.limitServices) g.GET("/oci-configs/:id/subscriptions", h.subscriptions) g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail) + + // 账单:发票与付款方式(OSP Gateway,仅主区域) + g.GET("/oci-configs/:id/invoices", h.invoices) + g.GET("/oci-configs/:id/invoices/:internalId/lines", h.invoiceLines) + g.GET("/oci-configs/:id/invoices/:internalId/pdf", h.invoicePdf) + g.POST("/oci-configs/:id/invoices/:internalId/pay", h.payInvoice) + g.GET("/oci-configs/:id/payment-methods", h.paymentMethods) g.GET("/oci-configs/:id/shapes", h.shapes) g.GET("/oci-configs/:id/images", h.images) g.GET("/oci-configs/:id/images/:imageId", h.getImage) @@ -61,6 +69,7 @@ func registerOciCompute(g *gin.RouterGroup, h *ociConfigHandler) { g.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic) g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic) g.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6) + g.POST("/oci-configs/:id/vnics/:vnicId/change-public-ip", h.changeVnicPublicIP) g.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic) } @@ -77,6 +86,10 @@ func registerOciNetwork(g *gin.RouterGroup, h *ociConfigHandler) { g.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet) g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet) g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet) + g.GET("/oci-configs/:id/reserved-ips", h.listReservedIPs) + g.POST("/oci-configs/:id/reserved-ips", h.createReservedIP) + g.PUT("/oci-configs/:id/reserved-ips/:publicIpId", h.assignReservedIP) + g.DELETE("/oci-configs/:id/reserved-ips/:publicIpId", h.deleteReservedIP) g.GET("/oci-configs/:id/security-lists", h.listSecurityLists) g.POST("/oci-configs/:id/security-lists", h.createSecurityList) g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList) @@ -100,6 +113,27 @@ func registerOciStorage(g *gin.RouterGroup, h *ociConfigHandler) { g.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume) } +// registerOciObjectStorage 对象存储:namespace、桶、对象、临时链接。 +func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) { + g.GET("/oci-configs/:id/object-storage/namespace", h.osNamespace) + g.GET("/oci-configs/:id/buckets", h.listBuckets) + g.POST("/oci-configs/:id/buckets", h.createBucket) + g.PUT("/oci-configs/:id/buckets/:bucket", h.updateBucket) + g.DELETE("/oci-configs/:id/buckets/:bucket", h.deleteBucket) + g.GET("/oci-configs/:id/buckets/:bucket/objects", h.listObjects) + g.DELETE("/oci-configs/:id/buckets/:bucket/objects", h.deleteObject) + g.POST("/oci-configs/:id/buckets/:bucket/objects/rename", h.renameObject) + g.POST("/oci-configs/:id/buckets/:bucket/objects/restore", h.restoreObject) + g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail) + // 小文件中转:预览/编辑直读直写,不签发 PAR + g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent) + g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent) + g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs) + g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR) + // parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配 + g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR) +} + // registerOciCost 成本快照。 func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) { g.GET("/oci-configs/:id/costs", h.costs) diff --git a/internal/api/tenant.go b/internal/api/tenant.go index 78b89d9..c11a14f 100644 --- a/internal/api/tenant.go +++ b/internal/api/tenant.go @@ -38,6 +38,11 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) { // @Summary 配置成本快照 // @Tags 成本 // @Param id path int true "配置 ID" +// @Param startTime query string false "起始时间 RFC3339,缺省为 endTime-30 天" +// @Param endTime query string false "结束时间 RFC3339,缺省为当前时刻" +// @Param granularity query string false "HOURLY / DAILY / MONTHLY,缺省 DAILY" +// @Param queryType query string false "COST / USAGE,缺省 COST" +// @Param groupBy query string false "分组维度,单维或复合(如 service,skuName),缺省 service" // @Success 200 {array} oci.CostItem // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/costs [get] diff --git a/internal/model/models.go b/internal/model/models.go index 1b9cfee..d114a2e 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -229,6 +229,7 @@ type CompartmentCache struct { OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"` OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"` Name string `gorm:"size:128" json:"name"` + ParentOCID string `gorm:"size:128" json:"parentId"` State string `gorm:"size:16" json:"lifecycleState"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/internal/oci/billing.go b/internal/oci/billing.go new file mode 100644 index 0000000..1974581 --- /dev/null +++ b/internal/oci/billing.go @@ -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 +} diff --git a/internal/oci/client.go b/internal/oci/client.go index f4afc61..dea17b9 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -50,6 +50,13 @@ type Client interface { ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error) // GetSubscription 查询单个订阅的完整信息。 GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error) + // 账单(OSP Gateway,仅主区域):发票列表/明细/PDF/付款与订阅付款方式。 + // ListInvoices 的 year>0 时按自然年过滤(timeInvoice 窗口),0 为全量。 + ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) + ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) + DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) + PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error + ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) // ListShapes 列出租户在目标区域可用的实例规格(带缓存)。 ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) // ListImages 列出可用的实例镜像。 @@ -91,6 +98,34 @@ type Client interface { DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error // 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。 ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) + ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) + // 对象存储:namespace、桶 CRUD、对象列表/删除/重命名/取回/元数据、预签名请求(PAR)。 + GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) + ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) + CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) + UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) + DeleteBucket(ctx context.Context, cred Credentials, region, name string) error + ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) + ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) + DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error + DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error + RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error + RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error + HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) + // 小文件中转:预览/编辑经面板直读直写,不签发 PAR;PutObject ifMatch 做并发保护。 + GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) + PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) + CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) + ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) + // ListPARsPage 单页列出 PAR:page 为上页游标(空取首页),返回下一页游标(空为末页)。 + ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) + DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error + // 保留公网 IP:列出 / 创建 / 绑定(instanceID 空为解绑) / 删除。 + ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error) + CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error) + AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error + AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error + DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error // VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。 @@ -184,8 +219,10 @@ type Client interface { // RealClient 基于官方 SDK 实现 Client。 type RealClient struct { - limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存 - shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存 + limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存 + shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存 + namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期 + homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用) } // NewClient 返回生产使用的真实客户端。 diff --git a/internal/oci/cost.go b/internal/oci/cost.go index 677f5d1..76e1274 100644 --- a/internal/oci/cost.go +++ b/internal/oci/cost.go @@ -14,15 +14,16 @@ import ( type CostQuery struct { StartTime time.Time EndTime time.Time - Granularity string // DAILY / MONTHLY + Granularity string // HOURLY / DAILY / MONTHLY QueryType string // COST / USAGE - GroupBy string // service / skuName / region 等维度 + GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子) } // CostItem 是一个时间桶内某分组维度的成本或用量。 type CostItem struct { TimeStart *time.Time `json:"timeStart"` GroupValue string `json:"groupValue"` + SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName) ComputedAmount float32 `json:"computedAmount"` ComputedQuantity float32 `json:"computedQuantity"` Currency string `json:"currency"` @@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari TimeUsageEnded: &common.SDKTime{Time: q.EndTime}, Granularity: granularity, QueryType: queryType, - GroupBy: []string{q.GroupBy}, + GroupBy: splitGroupBy(q.GroupBy), }, nil } -// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段。 +// splitGroupBy 把 "service,skuName" 复合维度拆成 Usage API 的 groupBy 列表。 +func splitGroupBy(groupBy string) []string { + parts := strings.Split(groupBy, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// dimValue 按维度名取响应条目上的分组值。 +func dimValue(item usageapi.UsageSummary, dim string) string { + switch strings.ToLower(strings.TrimSpace(dim)) { + case "skuname": + return deref(item.SkuName) + case "region": + return deref(item.Region) + case "compartmentname": + return deref(item.CompartmentName) + default: + return deref(item.Service) + } +} + +// toCostItem 把响应条目转为本地 DTO;首维进 GroupValue,复合分组的次维进 SubValue。 func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem { out := CostItem{ Currency: deref(item.Currency), @@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem { if item.ComputedQuantity != nil { out.ComputedQuantity = *item.ComputedQuantity } - switch strings.ToLower(groupBy) { - case "skuname": - out.GroupValue = deref(item.SkuName) - case "region": - out.GroupValue = deref(item.Region) - case "compartmentname": - out.GroupValue = deref(item.CompartmentName) - default: - out.GroupValue = deref(item.Service) + dims := splitGroupBy(groupBy) + if len(dims) == 0 { + dims = []string{"service"} + } + out.GroupValue = dimValue(item, dims[0]) + if len(dims) > 1 { + out.SubValue = dimValue(item, dims[1]) } return out } diff --git a/internal/oci/instance.go b/internal/oci/instance.go index 691f9c8..ceb98ed 100644 --- a/internal/oci/instance.go +++ b/internal/oci/instance.go @@ -49,6 +49,7 @@ type CreateInstanceInput struct { BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效 SubnetID string // 为空时自动创建 VCN 与子网 AssignPublicIP bool + ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP AssignIpv6 bool SSHPublicKey string // 与 RootPassword 互斥 RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init @@ -189,6 +190,10 @@ func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in Cr } func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) { + // 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑 + if in.ReservedPublicIPID != "" { + in.AssignPublicIP = false + } details := core.LaunchInstanceDetails{ CompartmentId: &compartmentID, AvailabilityDomain: &in.AvailabilityDomain, diff --git a/internal/oci/network.go b/internal/oci/network.go index aa48f0f..c25aca3 100644 --- a/internal/oci/network.go +++ b/internal/oci/network.go @@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc return toVCN(resp.Vcn), nil } -// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 / -// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。 +// DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 / +// 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。 func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error { vn, err := c.vcnClient(cred, region) if err != nil { diff --git a/internal/oci/objectstorage.go b/internal/oci/objectstorage.go new file mode 100644 index 0000000..9ca23b7 --- /dev/null +++ b/internal/oci/objectstorage.go @@ -0,0 +1,706 @@ +// 对象存储: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 + } + if in.AccessType == "AnyObjectReadWrite" || in.AccessType == "AnyObjectWrite" { + 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 +} diff --git a/internal/oci/objectstorage_test.go b/internal/oci/objectstorage_test.go new file mode 100644 index 0000000..642ce43 --- /dev/null +++ b/internal/oci/objectstorage_test.go @@ -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) + } + }) + } +} diff --git a/internal/oci/proxyhttp.go b/internal/oci/proxyhttp.go index c0344e5..05c6a62 100644 --- a/internal/oci/proxyhttp.go +++ b/internal/oci/proxyhttp.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "sync" "time" "golang.org/x/net/proxy" @@ -31,6 +32,20 @@ const ( proxyTLSHandshakeTimeout = 10 * time.Second ) +// 连接池参数:PerHost 须 > 批量删除并发数(service 层 16)——并发拨号竞速时 +// Transport 会投机新建连接,池上限过紧会让这些连接用一次即被丢弃(churn); +// IdleConnTimeout 显式关闭空闲连接(零值=永不关,只能等远端 ~65s 断开,连接会堆积)。 +const ( + proxyMaxIdleConns = 128 + proxyMaxIdleConnsPerHost = 32 + proxyIdleConnTimeout = 90 * time.Second +) + +// proxyClients 按代理配置复用 http.Client。SDK client 每次操作新建, +// 若 Transport 也随之新建则连接零复用:每个请求都要付完整的 +// TCP+SOCKS/CONNECT+TLS 握手(经代理 3+ 个 RTT),批量操作被拖到分钟级。 +var proxyClients sync.Map // ProxySpec(值) → *http.Client + // applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。 // 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。 func applyProxy(base *common.BaseClient, cred Credentials) { @@ -39,16 +54,21 @@ func applyProxy(base *common.BaseClient, cred Credentials) { } } -// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。 +// proxyHTTPClient 按代理配置取共享 http.Client;nil 或非法配置返回 nil(走直连)。 +// 同配置复用同一实例(连接池随之复用);调用方只可包装、不得改写其字段。 func proxyHTTPClient(p *ProxySpec) *http.Client { if p == nil || p.Host == "" || p.Port <= 0 { return nil } + if v, ok := proxyClients.Load(*p); ok { + return v.(*http.Client) + } tr := transportFor(p) if tr == nil { return nil } - return &http.Client{Transport: tr, Timeout: proxyClientTimeout} + v, _ := proxyClients.LoadOrStore(*p, &http.Client{Transport: tr, Timeout: proxyClientTimeout}) + return v.(*http.Client) } // HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK @@ -57,6 +77,16 @@ func HTTPClientFor(p *ProxySpec) *http.Client { return proxyHTTPClient(p) } +// pooledTransport 连接池参数统一的 Transport 骨架;阶段超时见常量注释。 +func pooledTransport() *http.Transport { + return &http.Transport{ + TLSHandshakeTimeout: proxyTLSHandshakeTimeout, + MaxIdleConns: proxyMaxIdleConns, + MaxIdleConnsPerHost: proxyMaxIdleConnsPerHost, + IdleConnTimeout: proxyIdleConnTimeout, + } +} + // transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。 func transportFor(p *ProxySpec) *http.Transport { addr := fmt.Sprintf("%s:%d", p.Host, p.Port) @@ -66,11 +96,10 @@ func transportFor(p *ProxySpec) *http.Transport { if p.Username != "" { u.User = url.UserPassword(p.Username, p.Password) } - return &http.Transport{ - Proxy: http.ProxyURL(u), - DialContext: dialer.DialContext, - TLSHandshakeTimeout: proxyTLSHandshakeTimeout, - } + tr := pooledTransport() + tr.Proxy = http.ProxyURL(u) + tr.DialContext = dialer.DialContext + return tr } var auth *proxy.Auth if p.Username != "" { @@ -84,8 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport { if !ok { return nil } - return &http.Transport{ - DialContext: cd.DialContext, - TLSHandshakeTimeout: proxyTLSHandshakeTimeout, - } + tr := pooledTransport() + tr.DialContext = cd.DialContext + return tr } diff --git a/internal/oci/proxyhttp_test.go b/internal/oci/proxyhttp_test.go index 92b2396..21c2145 100644 --- a/internal/oci/proxyhttp_test.go +++ b/internal/oci/proxyhttp_test.go @@ -29,6 +29,63 @@ func TestTransportForStageTimeouts(t *testing.T) { 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) + } }) } } diff --git a/internal/oci/publicip.go b/internal/oci/publicip.go index ca85cb9..f326e1f 100644 --- a/internal/oci/publicip.go +++ b/internal/oci/publicip.go @@ -3,7 +3,9 @@ package oci import ( "context" "fmt" + "time" + "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/core" ) @@ -62,7 +64,7 @@ func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIP } // ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。 -// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作。 +// 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新的临时 IP。 func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) { vn, err := c.vcnClient(cred, region) if err != nil { @@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential if err != nil { return "", err } - if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil { - if existing.Lifetime == core.PublicIpLifetimeReserved { - return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually") - } - if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil { - return "", fmt.Errorf("delete old public ip: %w", err) - } + return replaceEphemeralPublicIP(ctx, vn, cred, priv) +} + +// ChangeVnicPublicIP 实现 Client:为指定 VNIC 的主私有 IP 更换临时公网 IP(次要网卡场景)。 +func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return "", err + } + priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID) + if err != nil { + return "", err + } + return replaceEphemeralPublicIP(ctx, vn, cred, priv) +} + +// replaceEphemeralPublicIP 释放私有 IP 上现有公网 IP(临时删除、保留解绑)并分配新临时 IP。 +func replaceEphemeralPublicIP(ctx context.Context, vn core.VirtualNetworkClient, cred Credentials, priv core.PrivateIp) (string, error) { + if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil { + return "", err } // OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{ @@ -94,6 +109,45 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential return deref(resp.IpAddress), nil } +// detachExistingPublicIP 释放私有 IP 上已绑定的公网 IP: +// 临时 IP 直接删除;保留 IP 仅解绑(回到未分配状态,资源不删除)。 +func detachExistingPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) error { + existing := lookupPublicIP(ctx, vn, privateIPID) + if existing == nil { + return nil + } + if existing.Lifetime != core.PublicIpLifetimeReserved { + if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil { + return fmt.Errorf("delete old public ip: %w", err) + } + return nil + } + _, err := vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{ + PublicIpId: existing.Id, + UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: common.String("")}, + }) + if err != nil { + return fmt.Errorf("unassign reserved public ip: %w", err) + } + waitPublicIPDetached(ctx, vn, privateIPID) + return nil +} + +// waitPublicIPDetached 短轮询等私有 IP 上的旧绑定释放;保留 IP 解绑为异步操作, +// 立即创建新临时 IP 可能撞上未释放的旧绑定。超时不报错,交由后续创建操作反馈。 +func waitPublicIPDetached(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) { + for i := 0; i < 10; i++ { + if lookupPublicIP(ctx, vn, privateIPID) == nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } +} + // primaryVnic 返回实例的主 VNIC。 func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) { vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID) diff --git a/internal/oci/reservedip.go b/internal/oci/reservedip.go new file mode 100644 index 0000000..9f3b3b0 --- /dev/null +++ b/internal/oci/reservedip.go @@ -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 +} diff --git a/internal/oci/vcndelete.go b/internal/oci/vcndelete.go index 0716447..6cd9ffc 100644 --- a/internal/oci/vcndelete.go +++ b/internal/oci/vcndelete.go @@ -9,7 +9,7 @@ import ( ) // 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、 -// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。 +// 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。 // 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState, // 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。 @@ -32,6 +32,9 @@ func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID s if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil { return err } + if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil { + return err + } if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil { return fmt.Errorf("delete vcn %s: %w", vcnID, err) } @@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com } return nil } + +// deleteVcnNsgs 删除 VCN 下全部网络安全组:遗留 NSG 会使 DeleteVcn 报 +// 409 associated with NSG。仅按 VcnId 过滤,跨 compartment 的 NSG 也能覆盖; +// 子网删除后 VNIC 已不存在,NSG 不再有关联,可直接删除。 +func deleteVcnNsgs(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error { + resp, err := vn.ListNetworkSecurityGroups(ctx, core.ListNetworkSecurityGroupsRequest{VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list network security groups: %w", err) + } + for _, g := range resp.Items { + if g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminated || + g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminating { + continue + } + if _, err := vn.DeleteNetworkSecurityGroup(ctx, core.DeleteNetworkSecurityGroupRequest{ + NetworkSecurityGroupId: g.Id, + }); err != nil { + return fmt.Errorf("delete network security group %s: %w", deref(g.DisplayName), err) + } + } + return nil +} diff --git a/internal/oci/vnic.go b/internal/oci/vnic.go index 54ced3a..0460c1c 100644 --- a/internal/oci/vnic.go +++ b/internal/oci/vnic.go @@ -31,6 +31,8 @@ type AttachVnicInput struct { AssignPublicIP bool `json:"assignPublicIp"` // AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。 AssignIpv6 bool `json:"assignIpv6"` + // ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。 + ReservedPublicIPID string `json:"reservedPublicIpId"` } // ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。 @@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i if err != nil { return Vnic{}, err } + // 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次 + if in.ReservedPublicIPID != "" { + in.AssignPublicIP = false + } details := &core.CreateVnicDetails{ SubnetId: &in.SubnetID, AssignPublicIp: &in.AssignPublicIP, diff --git a/internal/service/attachment.go b/internal/service/attachment.go index 033fbc8..44f31f4 100644 --- a/internal/service/attachment.go +++ b/internal/service/attachment.go @@ -7,6 +7,18 @@ import ( "oci-portal/internal/oci" ) +// ChangeVnicPublicIP 更换指定 VNIC 的临时公网 IP,返回新地址(旧保留 IP 自动解绑)。 +func (s *OciConfigService) ChangeVnicPublicIP(ctx context.Context, id uint, region, vnicID string) (string, error) { + if vnicID == "" { + return "", fmt.Errorf("change vnic public ip: vnicId is required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return "", err + } + return s.client.ChangeVnicPublicIP(ctx, cred, region, vnicID) +} + // ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。 func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) { cred, err := s.credentialsByID(ctx, id) @@ -46,7 +58,7 @@ func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, i return s.client.ListInstanceVnics(ctx, cred, region, instanceID) } -// AttachVnic 为实例附加次要 VNIC。 +// AttachVnic 为实例附加次要 VNIC;选了保留 IP 时由后台等网卡就绪后绑定。 func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) { if instanceID == "" || in.SubnetID == "" { return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required") @@ -55,7 +67,16 @@ func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, inst if err != nil { return oci.Vnic{}, err } - return s.client.AttachVnic(ctx, cred, region, instanceID, in) + vnic, err := s.client.AttachVnic(ctx, cred, region, instanceID, in) + if err != nil { + return vnic, err + } + if in.ReservedPublicIPID != "" { + s.goBind(func() { + s.bindReservedIPToVnicWhenReady(cred, region, instanceID, vnic.AttachmentID, in.ReservedPublicIPID) + }) + } + return vnic, nil } // DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。 diff --git a/internal/service/billing.go b/internal/service/billing.go new file mode 100644 index 0000000..ee512ea --- /dev/null +++ b/internal/service/billing.go @@ -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) +} diff --git a/internal/service/billing_test.go b/internal/service/billing_test.go new file mode 100644 index 0000000..54f9a25 --- /dev/null +++ b/internal/service/billing_test.go @@ -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) + } + }) + } +} diff --git a/internal/service/instance.go b/internal/service/instance.go index ae041be..b236a84 100644 --- a/internal/service/instance.go +++ b/internal/service/instance.go @@ -37,6 +37,9 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci. if count < 1 || count > 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 { 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))) 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) } return instances, failures, nil diff --git a/internal/service/objectstorage.go b/internal/service/objectstorage.go new file mode 100644 index 0000000..8bd5ca9 --- /dev/null +++ b/internal/service/objectstorage.go @@ -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 +} diff --git a/internal/service/objectstorage_test.go b/internal/service/objectstorage_test.go new file mode 100644 index 0000000..a4d811f --- /dev/null +++ b/internal/service/objectstorage_test.go @@ -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") + } +} diff --git a/internal/service/ociconfig.go b/internal/service/ociconfig.go index b866b21..812c642 100644 --- a/internal/service/ociconfig.go +++ b/internal/service/ociconfig.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "time" "gorm.io/gorm" @@ -23,11 +24,17 @@ type OciConfigService struct { // auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟), // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 auditRaw *cache.Cache + // bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。 + bindWG sync.WaitGroup + bindStop chan struct{} } // NewOciConfigService 组装依赖。 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 注入租户删除提交后的任务内存状态同步依赖。 diff --git a/internal/service/reservedip.go b/internal/service/reservedip.go new file mode 100644 index 0000000..7b8af70 --- /dev/null +++ b/internal/service/reservedip.go @@ -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): + } + } +} diff --git a/internal/service/reservedip_test.go b/internal/service/reservedip_test.go new file mode 100644 index 0000000..e471aba --- /dev/null +++ b/internal/service/reservedip_test.go @@ -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")) +} diff --git a/internal/service/scopecache.go b/internal/service/scopecache.go index 093ddd2..55337d9 100644 --- a/internal/service/scopecache.go +++ b/internal/service/scopecache.go @@ -57,7 +57,8 @@ func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint, rows := make([]model.CompartmentCache, 0, len(comps)) for _, c := range comps { 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 { @@ -191,16 +192,29 @@ func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]o Order("name").Find(&rows).Error; err != nil { return nil, fmt.Errorf("load compartment cache: %w", err) } - if len(rows) == 0 { + if len(rows) == 0 || legacyCompartmentCache(rows) { return s.refreshCompartmentCache(ctx, cfg) } comps := make([]oci.Compartment, 0, len(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 } +// 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 并覆盖缓存。 func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) { cred, err := s.credentialsOf(cfg) diff --git a/internal/service/scopecache_test.go b/internal/service/scopecache_test.go index 178a178..b1c0c5e 100644 --- a/internal/service/scopecache_test.go +++ b/internal/service/scopecache_test.go @@ -16,7 +16,7 @@ func multiScopeClient() *fakeClient { {Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"}, }, 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" { 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.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) { client := multiScopeClient() svc := newTestService(t, client) diff --git a/internal/service/usage.go b/internal/service/usage.go index d51f567..b5bc9a9 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -73,8 +73,11 @@ func applyCostDefaults(q *oci.CostQuery) { func alignDown(t time.Time, granularity string) time.Time { t = t.UTC() - if granularity == "MONTHLY" { + switch granularity { + case "MONTHLY": 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) } @@ -84,8 +87,11 @@ func alignUp(t time.Time, granularity string) time.Time { if down.Equal(t.UTC()) { return down } - if granularity == "MONTHLY" { + switch granularity { + case "MONTHLY": return down.AddDate(0, 1, 0) + case "HOURLY": + return down.Add(time.Hour) } return down.AddDate(0, 0, 1) } diff --git a/internal/service/vnic_test.go b/internal/service/vnic_test.go index c4048fb..3228088 100644 --- a/internal/service/vnic_test.go +++ b/internal/service/vnic_test.go @@ -3,7 +3,9 @@ package service import ( "context" "strings" + "sync" "testing" + "time" "oci-portal/internal/oci" ) @@ -12,17 +14,35 @@ import ( type vnicStubClient struct { *fakeClient + mu sync.Mutex vnics []oci.Vnic attached oci.AttachVnicInput attachInst string detachedID string ipv6Vnic string + boundVnic string // AssignReservedIPToVnic 记录 + boundIP string } 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 } +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) { f.attachInst, f.attached = instanceID, in 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) } } + +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) +}