8 Commits
Author SHA1 Message Date
wangdefa f1914880ac 发布 v0.8.2
CI / test (push) Successful in 21s
Release / release (push) Successful in 54s
2026-07-22 22:15:23 +08:00
wangdefa a95a8255bf 用户 API Key 管理:增删查、启用签名凭据;升级 SDK
CI / test (push) Successful in 48s
2026-07-22 19:38:14 +08:00
wangdefa 8894330eba 发布 v0.8.1
CI / test (push) Successful in 21s
Release / release (push) Successful in 42s
2026-07-22 17:10:58 +08:00
wangdefa f51fb6c722 修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s
2026-07-22 16:51:23 +08:00
wangdefa 0614ef22af 发布 v0.8.0
CI / test (push) Successful in 22s
Release / release (push) Successful in 51s
2026-07-21 19:43:02 +08:00
wangdefa 33e92a65e2 成本查询限窗过滤;桶级 PAR 放开列举
CI / test (push) Successful in 35s
2026-07-21 19:26:33 +08:00
wangdefa f40f2a20e8 新增对象存储/账单/保留IP模块,删桶并发提速与成本增强
CI / test (push) Successful in 33s
2026-07-21 12:11:05 +08:00
wangdefa 9cfde8b702 发布 v0.7.3
CI / test (push) Successful in 20s
Release / release (push) Successful in 58s
2026-07-17 17:09:06 +08:00
103 changed files with 12935 additions and 720 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.6 0.6.7
+32
View File
@@ -27,3 +27,35 @@ go func() {
- 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。 - 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。
- cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前** - cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前**
- 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add``Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。 - 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add``Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。
## HTTP 处理器不得同步执行长任务
**问题**:`POST /tasks/:id/run` 曾在处理器内同步跑完整个任务(AI 探测挂上模型验证后单次 24~90s),前端点击后长时间零反馈;且执行入口无并发防护,双击/与 cron 重叠会真跑两次。
**约定**(task.go `TriggerTask` 为范例):
- 可能超过 1~2s 的执行,API 只做「异步触发」:校验存在 → 抢占执行权 → 挂 WaitGroup 的 goroutine 执行 → 立即 202;结果落任务日志/通知,由前端轮询呈现。
- 在飞防重用 `map[uint]bool` + 专属 mutex(`beginRun`/`endRun`):手动重复触发返回哨兵错误(API 映射 409),cron 重叠触发静默跳过。执行权的获取必须在触发方同步完成,不能移进 goroutine(否则有双触发窗口)。
- 后台执行 goroutine 一律纳入服务的 WaitGroup,`Stop()``cron.Stop()` 之后追加 `runWG.Wait()` 收尾。
## 上游 HTTP 超时:流式/长调用禁用 http.Client.Timeout 总超时
**问题**(2026-07 responses 直通 60s 断流):`http.Client.Timeout` 覆盖单次请求全生命周期(发起→响应头→**body 读完**)。OCI SDK 默认 60s(`common/client.go` defaultTimeout),对 SSE 流式(读 body 无上限)与 multi-agent/搜索类慢模型(>60s 才回响应头)必然掐断;且超时被 `switchable` 判为可重试,还会误伤渠道熔断计数。
**约定**(genai_responses.go 为范例):
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
**问题**:每个 OCI 操作都新建 SDK client,代理路径连带每次 new 一个 `http.Transport`——连接零复用,每请求付整条 TCP+SOCKS5+TLS 握手(经代理 3+ RTT ≈ 1s+);且对象存储每操作先远程取一次 namespace,单条 PAR 删除被放大到 ~2.3s,几千条串行删除撑爆 30 分钟 purge 窗口。用完即弃的 Transport 未设 `IdleConnTimeout`(零值=客户端永不关),空闲连接堆到远端 ~65s 超时才断,代理侧稳态挂着几十条连接。
**约定**:
- 代理出站 `http.Client` 一律经 proxyhttp.go 的包级 `proxyClients` 缓存(按 ProxySpec 值复用),不得在调用点新建 per-request Transport;共享实例只可包装(dispatcher wrap),不得改写其字段。连接池参数集中在 `pooledTransport()`:`MaxIdleConnsPerHost` 须 ≥ 批量并发数。
- 租户常量(对象存储 namespace 等)在 `RealClient``sync.Map` 进程内缓存(照 `limitDefs`/`shapes` 模式),不逐操作远程取。
- 批量逐条调用统一走 service 层 `forEachConcurrently` + `bulkDeleteWorkers`(16);对应的 SDK 删除请求带 `bulkRetryPolicy`(429/5xx 退避),并发提速与限流保护成对出现,二者缺一不可。
+17 -60
View File
@@ -1,75 +1,32 @@
# Database Guidelines # 数据库规范
> Database patterns and conventions for this project. > GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。
--- ## 租户级数据删除
## Overview - 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
<!-- - 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
Document your project's database conventions here. - 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
Questions to answer:
- What ORM/query library do you use?
- How are migrations managed?
- What are the naming conventions for tables/columns?
- How do you handle transactions?
-->
(To be filled by the team)
---
## Query Patterns
<!-- How should queries be written? Batch operations? -->
### 租户级数据删除
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。 - 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
### 大集合谓词用子查询,禁止展开 IN 列表 ## 大集合谓词用子查询,禁止展开 IN 列表
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。 - 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。 - GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。 - 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。 - 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
--- ## Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
## Migrations **Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
<!-- How to create and run migrations --> **Cause**:GORM `Save``UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
(To be filled by the team) **Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`
--- ## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
## Naming Conventions
<!-- Table names, column names, index names -->
(To be filled by the team)
---
## Common Mistakes
<!-- Database-related mistakes your team has made -->
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
**Cause**:GORM `Save``UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。 **Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
@@ -79,7 +36,7 @@ Questions to answer:
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。 **Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
### Common Mistake: 需要"条件更新多列含 NULL"时的写法 ## Common Mistake: 需要"条件更新多列含 NULL"时的写法
```go ```go
// 正确:零写库条件 + 显式 NULL // 正确:零写库条件 + 显式 NULL
@@ -88,13 +45,13 @@ db.Model(&model.AiChannel{}).
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")}) Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
``` ```
### `serializer:json` 字段走 map Updates 时手动 marshal ## `serializer:json` 字段走 map Updates 时手动 marshal
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化; - 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
-`Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测; -`Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。 - 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
### Common Mistake: 进程内缓存键漏掉查询维度 ## Common Mistake: 进程内缓存键漏掉查询维度
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。 **Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
+3 -1
View File
@@ -1,6 +1,6 @@
# 后端目录结构 # 后端目录结构
> Go 后端工程的目录职责与代码组织约定。 > Go 后端工程(`oci-portal/`)的目录职责与代码组织约定。
## 目录职责 ## 目录职责
@@ -30,3 +30,5 @@ oci-portal/
- `defer` 紧跟资源获取语句,成对管理释放。 - `defer` 紧跟资源获取语句,成对管理释放。
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。 - 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。 - 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
- 路由参数:资源 id 可能含 `/` 等 URL 保留字符时(如 OCI PAR id),一律经 query 传递而非路径参数 —— gin 路径段不匹配 `%2F`,会直接 404(2026-07 round6 教训)。
- OSP Gateway(账单/发票,`internal/oci/billing.go`):服务只在租户主区域提供,每个请求都带 `ospHomeRegion` 且客户端 `SetRegion` 到主区域 —— 主区域公共名经测活 `HomeRegionKey` + `RegionByKey` 解析,勿用凭据 region 直连;SDK 金额是 `*float32`,输出前经 `money()` 四舍五入抹掉 float64 转换噪音;`PayInvoice``Email` 是 API 必填(付款回执),service 层校验后透传。
+14
View File
@@ -31,3 +31,17 @@ func sanitizeURLError(err error) error {
``` ```
**预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。 **预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。
## OCI 错误按语义分类,不要只看 HTTP 状态码
**问题**:OCI 同一状态码承载多种语义,按状态码一刀切会误判。已踩过的案例(GenAI 网关):
- **404 双义**:`NotAuthorizedOrNotFound`(消息 "Authorization failed or requested resource not found")是**租户级**无权限/无策略;"Entity with key … not found" 是**模型级**——该模型 OCID 在此区域无按需供给。前者应定论渠道无配额,后者应剔除该模型换下一个候选。
- **400 微调基座**:"Not allowed to call finetune base model …, use Endpoint: false" 表示模型在该区域仅作微调基座(dedicated 供给),同样是模型级不可用,不是请求参数错误。
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As``common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
## 外部响应体必须限长,超限报错而非静默截断
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
+91
View File
@@ -0,0 +1,91 @@
# IdP 图标上传与清理契约
## 1. Scope / Trigger
- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**,
孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。
## 2. Signatures
```text
POST /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
```
```typescript
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>
interface IdpIconUpload {
url: string // 写入 IdP iconUrl 的公网地址
fileName: string // 仅用于清理的存储标识
}
```
上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):
```text
POST /storage/v1/Images multipart: file + fileName
DELETE /storage/v1/Images query: fileName
```
## 3. Contracts
- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制;
路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。
- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成
`idp-icon-<32 lowercase hex><lowercase ext>`:既避免并发/迟到响应下同名互相
覆盖,又让存储名成为不可猜测的清理凭据。
- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg),
不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管,
传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起
(2026-07 曾过度实现完整解码套件后精简)。
- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.<allowed ext>`,
不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon,
用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。
- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP;
回滚成功按普通失败,回滚失败/无法确认返回 `201`
`setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`;
内部原因只按 requestId 写服务端日志,不进响应。
- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置,
`primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。
## 4. Validation & Error Matrix
| 条件 | HTTP |
| --- | --- |
| 缺少 multipart `file`、文件为空或文件名非法 | 400 |
| 文件本体超过 1 MiB | 413 |
| 扩展名不支持或与文件头魔数不符 | 415 |
| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 |
| 上游 DELETE 返回 404 | 204 |
| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` |
| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 |
## 5. Tests / Verification Required
- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。
- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游;
DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。
- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。
- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支;
后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。
- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。
## 6. Wrong vs Correct
```typescript
// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。
onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId))
// Correct:上传时捕获原始元组,清理只按元组走,失败静默。
pendingIcon = { ...uploaded, cfgId, domainId }
void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {})
```
反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 +
卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验——
复杂度与威胁模型不匹配,已于 2026-07 精简移除。
+4 -4
View File
@@ -1,6 +1,6 @@
# Backend Development Guidelines(oci-portal 后端规范) # Backend Development Guidelines(oci-portal 后端规范)
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。 > Go 后端(`oci-portal/`)编码规范入口。
--- ---
@@ -19,9 +19,9 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 | | [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 | | [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
| [Testing](./testing.md) | table-driven 测试要求 | 已填 | | [Testing](./testing.md) | table-driven 测试要求 | 已填 |
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 填 | | [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 填 |
| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 |
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 | | [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
--- ---
@@ -32,7 +32,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`) - [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md)) - [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回 - [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
- [ ] 复用已有封装,不重复造轮子(`../guides/code-reuse-thinking-guide.md`) - [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式)
## Quality Check ## Quality Check
@@ -1,51 +0,0 @@
# Logging Guidelines
> How logging is done in this project.
---
## Overview
<!--
Document your project's logging conventions here.
Questions to answer:
- What logging library do you use?
- What are the log levels and when to use each?
- What should be logged?
- What should NOT be logged (PII, secrets)?
-->
(To be filled by the team)
---
## Log Levels
<!-- When to use each level: debug, info, warn, error -->
(To be filled by the team)
---
## Structured Logging
<!-- Log format, required fields -->
(To be filled by the team)
---
## What to Log
<!-- Important events to log -->
(To be filled by the team)
---
## What NOT to Log
<!-- Sensitive data, PII, secrets -->
(To be filled by the team)
+7
View File
@@ -17,3 +17,10 @@
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。 - 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。 - 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
## 日志回传链路(logrelay)资源命名与描述纪律
- **命名派生**:Topic 前缀、IAM Policy 名、SCH Connector DisplayName 一律由 `relayResourceNames(tenancyOCID)`(见 `internal/oci/logrelay_names.go`)基于 `SHA-256(tenancyOCID)[:4]` 派生 `<8hex>-audit` / `<8hex>-audit-p`。**不得在 `logrelay.go` 里再引入品牌明文**(`oci-portal` / `ociportal` / `logs` 等),否则跨租户恒定字面量会成为 Oracle 内部风控识别「共用同一套 oci-portal」的强指纹;背景与证据分档见调研档案 `docs/oci-sdk-caller-fingerprint.md`
- **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。
- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。
- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。
@@ -16,3 +16,18 @@
- Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()` - Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()`
- 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)` - 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`
- 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。 - 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。
## JSON 联合类型与内容留档保真(2026-07 内容日志失真教训)
- aiwire 里兼容多形态的联合类型(如 `RespInput` string|数组)**必须成对实现**
`UnmarshalJSON``MarshalJSON`:只写 Unmarshal 时,任何再序列化(内容日志、
测试快照)都会退化成 Go 字段名形态(`{"Text":"","Items":[...],"IsArray":true}`),
排障者复制它复现会直接 400。参考 `aiwire/openai.go Content` 的保形写法,
往返用 table-driven 测试锁定(`aiwire/responses_test.go`)。
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal`
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
## 多字段设置接口用字段级 PATCH,不用全量 PUT
「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。
+20 -1
View File
@@ -6,6 +6,26 @@
- 断言失败时输出 `got/want` 对比,便于定位。 - 断言失败时输出 `got/want` 对比,便于定位。
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()` - 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`
## 上传接口边界测试
- “文件最大 N 字节”与 HTTP 请求体上限不是同一个契约:multipart boundary、
part header 和文件名会额外占空间。路由级 body limit 必须为封装开销留余量,
service 再对文件字节数执行精确上限。
- 新增或修改上传接口时,完整路由测试至少覆盖:恰好 N 字节成功、N+1 返回
413、空文件返回 400、伪造扩展名返回 415。
- 内容校验深度与威胁模型匹配:管理员向自己租户上传、由上游域名托管的低价值
资源,扩展名白名单 + 魔数嗅探即可,不引入完整解码/结构校验
(2026-07 IdP 图标曾过度实现后精简,见 idp-icon-upload.md)。
- 临时远端资源使用服务端随机名,测试相同客户端文件名不会复用上游对象;清理接口
只接受该功能生成的不可猜测标识,不能因共享 `images/` 前缀删除其他业务资产。
## 多步远端写入测试
- `创建 A → 配置 B` 这类流程至少覆盖:创建失败、B 失败且 A 回滚成功、B 失败且
A 回滚失败/状态未知。只有确认回滚成功才能返回普通失败。
- 部分成功响应必须有稳定机器码和关联 ID,前端据此保留已被 A 引用的临时资源;
底层上游错误只进服务端日志,测试断言不会泄露到 HTTP body。
## 常见坑:`:memory:` SQLite 每个连接是独立库 ## 常见坑:`:memory:` SQLite 每个连接是独立库
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx` **症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`
@@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连
``` ```
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。 **预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
@@ -1,223 +0,0 @@
# Code Reuse Thinking Guide
> **Purpose**: Stop and think before creating new code - does it already exist?
---
## The Problem
**Duplicated code is the #1 source of inconsistency bugs.**
When you copy-paste or rewrite existing logic:
- Bug fixes don't propagate
- Behavior diverges over time
- Codebase becomes harder to understand
---
## Before Writing New Code
### Step 1: Search First
```bash
# Search for similar function names
grep -r "functionName" .
# Search for similar logic
grep -r "keyword" .
```
### Step 2: Ask These Questions
| Question | If Yes... |
|----------|-----------|
| Does a similar function exist? | Use or extend it |
| Is this pattern used elsewhere? | Follow the existing pattern |
| Could this be a shared utility? | Create it in the right place |
| Am I copying code from another file? | **STOP** - extract to shared |
---
## Common Duplication Patterns
### Pattern 1: Copy-Paste Functions
**Bad**: Copying a validation function to another file
**Good**: Extract to shared utilities, import where needed
### Pattern 2: Similar Components
**Bad**: Creating a new component that's 80% similar to existing
**Good**: Extend existing component with props/variants
### Pattern 3: Repeated Constants
**Bad**: Defining the same constant in multiple files
**Good**: Single source of truth, import everywhere
### Pattern 4: Repeated Payload Field Extraction
**Bad**: Multiple consumers cast the same JSON/event fields locally:
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
This is duplicated contract logic even when the code is only two lines. Each
consumer now has its own definition of what a valid payload means.
**Good**: Put the decoder, type guard, or projection next to the data owner:
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
**Rule**: If the same untyped payload field is read in 2+ places, create a
shared type guard / normalizer / projection before adding a third reader.
---
## When to Abstract
**Abstract when**:
- Same code appears 3+ times
- Logic is complex enough to have bugs
- Multiple people might need this
**Don't abstract when**:
- Only used once
- Trivial one-liner
- Abstraction would be more complex than duplication
---
## After Batch Modifications
When you've made similar changes to multiple files:
1. **Review**: Did you catch all instances?
2. **Search**: Run grep to find any missed
3. **Consider**: Should this be abstracted?
### Reducers Should Use Exhaustive Structure
When state is derived from action-like values (`action`, `kind`, `status`,
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
```typescript
// BAD - action-specific state transitions are hard to audit
if (action === "opened") { ... }
else if (action === "comment") { ... }
else if (action === "status") { ... }
// GOOD - one reducer owns the transition table
switch (event.action) {
case "opened":
...
return;
case "comment":
...
return;
}
```
This matters when the event log is the source of truth. A reducer is the
documented replay model; display code and commands should not duplicate pieces
of that replay model.
---
## Checklist Before Commit
- [ ] Searched for existing similar code
- [ ] No copy-pasted logic that should be shared
- [ ] No repeated untyped payload field extraction outside a shared decoder
- [ ] Constants defined in one place
- [ ] Similar patterns follow same structure
- [ ] Reducer/action transitions live in one reducer or command dispatcher
---
## Gotcha: Python if/elif/else Exhaustive Check
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
**Example** (`cli_adapter.py`):
```python
# BAD: "gemini" falls through to else, returns "claude"
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
else:
return "claude" # gemini silently gets "claude"!
# GOOD: explicit branch for every platform
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
elif self.platform == "gemini":
return "gemini"
else:
return "claude"
```
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
---
## Gotcha: Asymmetric Mechanisms Producing Same Output
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
**Prevention**:
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
- When migrating directory structures, search for ALL code paths that reference the old structure
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
---
## Template File Registration (Trellis-specific)
When adding new files to `src/templates/trellis/scripts/`:
**Single registration point**: `src/templates/trellis/index.ts`
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
2. Add to `getAllScripts()` Map
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
### Quick Checklist for New Scripts
```bash
# After adding a new .py file, verify it's in getAllScripts():
grep -l "newFileName" src/templates/trellis/index.ts # Should match
```
### Template Sync Convention
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
```bash
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
```
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
+67
View File
@@ -2,6 +2,73 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.8.2]
### Added
- 租户 IAM 用户新增 API Key 管理:支持列出密钥并标注当前签名凭据、提供 OCI CLI 配置模板、由服务端生成 RSA-2048 密钥对并上传公钥(私钥仅在创建响应中返回),以及按指纹删除单把密钥;新密钥可在验证 OCI 可用后启用为面板签名凭据并加密保存,旧密钥保留,当前在用密钥禁止直接删除
### Changed
- 升级 OCI Go SDK 至 v65.121.1
## [0.8.1]
### Added
- 身份提供商管理新增图标上传与删除:支持 PNG / JPEG / GIF / SVG / WebP / ICO,限制 1 MB,并校验扩展名与文件内容;图片存入身份域公共存储,返回可用于 `iconUrl` 的地址及后续清理所需的 `fileName`
### Changed
- 安全设置与 OAuth Provider 配置接口由全量 `PUT` 改为字段级 `PATCH`,仅更新请求中出现的字段,避免并发编辑时回滚其他设置;密钥字段仍支持缺省沿用、空串清除
- 对象存储桶级 PAR 新增只读 `AnyObjectRead` 与只写 `AnyObjectWrite`,有效期上限由 30 天放宽到 100 年;仅含读取能力的桶级链接开放对象列举,非法类型或有效期稳定返回 400
### Fixed
- 修复总览将不同币种成本直接相加;响应新增按币种拆分的 `series`,顶层字段保持主币种兼容口径
- 修复抢机任务编辑把剩余台数误当目标台数、丢失已完成进度或鉴权失败计数;任务更新增加乐观并发保护,状态已变化时返回 409 而不覆盖执行结果
- 修复对象内容保存实际受统一 1 MB 请求体限制、无法达到声明的 5 MB 上限;非空桶后台删除现会中止未完成分片上传,并合并同桶重复清空请求
- 修复 SAML IdP 创建与启用边界:域要求主邮箱时自动补邮箱映射,JIT 后置配置失败会尝试回滚并在无法确认时返回 `setupWarning`,首次 IdP 可正确加入默认登录策略,免 MFA 规则补齐 OCI consent 字段
- 修复 AI 网关超大非流式响应被截断后仍按成功返回、内容日志关闭后在途请求仍写入正文,以及大量 AI 日志清理 / 租户删除可能触发数据库参数上限
### Security
- 日志回传 Topic / Policy / Connector 改为按租户派生的中性名称并移除品牌描述,降低跨租户固定指纹;旧命名资源仍可识别复用
- 租户导入在首次 OCI 请求前即校验并应用指定代理,SAML 元数据下载同样复用租户代理且非法配置失败关闭;禁用密码登录与解绑外部身份改为事务串行校验,防止并发操作导致账号失去全部登录方式
## [0.8.0]
### Added
- 新增对象存储管理:支持按区域 / 区间创建存储桶(可选可见性、存储层和版本控制),后续可更新可见性 / 版本控制;对象支持按虚拟目录分页浏览、删除、重命名、查看元数据和取回 Archive,非空桶可转后台清空全部对象版本与 PAR 后删除
- 新增对象内容中转接口:小文件预览与编辑不再签发 PAR,读取上限 20 MB、写入上限 5 MB,保存支持 `If-Match` 并发保护;上传、下载和分享继续使用 PAR 直连
- 新增对象存储 PAR 管理:支持对象级只读 / 只写 / 读写链接和桶级读写(`AnyObjectReadWrite`)链接、自定义有效期、游标分页、单条 / 全部删除;桶级链接允许列举对象
- 新增账单管理(OSP Gateway,仅主区域):按自然年查询发票、查看费用明细、下载 PDF、查看付款方式,并可使用默认付款方式提交在线支付
- 新增保留公网 IP 管理:支持按区间列出、创建、绑定 / 解绑和删除;创建实例或附加 VNIC 时可在资源就绪后自动绑定,并新增指定 VNIC 更换临时公网 IP
### Changed
- 成本 / 用量查询新增 `HOURLY` 粒度和双维度分组(如 `service,skuName`),响应通过 `subValue` 返回第二维,支持「今天」小时视图与服务下的 SKU 明细
- 主网卡或次要 VNIC 更换公网 IP 时,如原地址为保留 IP,改为自动解绑并保留该地址,再分配新的临时 IP(此前会拒绝操作)
- 对象存储 namespace 与代理出站客户端改为进程内复用,对象版本 / PAR 清理由固定并发执行,显著降低大量对象或分享链接场景下的删桶耗时与连接开销
### Fixed
- 修复 `HOURLY` 成本查询因 OCI Usage API 将起点下扩到 UTC 当日而夹带窗口外数据;服务端现严格按 `[startTime, endTime)` 过滤返回行
- 修复 VCN 级联删除遗漏网络安全组(NSG),导致其他资源已清理后仍因关联关系返回 409
- 修复区间缓存未保存 / 返回 `parentId`,导致多层区间在前端被平铺;旧缓存检测到层级缺失时会自动实时刷新并回写
## [0.7.3]
### Changed
- `LaunchInstance` 移出回传关键事件清单(Connector 过滤条件与「云端事件」告警白名单共用):创建以面板自身(手动 / 抢机反复尝试)发起为主,回传即噪声(高频抢机可在一两天内刷掉 2 万条存量上限);终止与电源操作等外部风险信号保留。链路幂等创建新增过滤条件对账,存量链路在租户详情点「一键创建」即原地更新条件,无需拆除重建
- 实例规格清单(ListShapes)透传 OCI `quotaNames`(与 compute limits 配额名同名),供前端数据驱动配额与可用域可用性判定
### Fixed
- 修复「云端事件」实例生命周期通知从未生效:Audit v2 计算类事件类型带 `.begin` / `.end` 阶段后缀(如 `…ComputeApi.LaunchInstance.end`),事件短名取末段得到 `begin` / `end`,不在关键事件集合内而被跳过。现剥离阶段后缀判定,成对事件只推 `.begin`(携带操作者 / IP / 成败;`.end` 无操作者且信息重复);`X failed with response 'Err'` 形态消息判为失败并提取引号内错误码作告警补充说明;告警资源名在 `resourceName` 缺失时回退外层 `source`(实例名)
## [0.7.2] ## [0.7.2]
### Added ### Added
+1 -1
View File
@@ -1 +1 @@
v0.7.2 v0.8.2
+8 -4
View File
@@ -13,12 +13,10 @@
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发) [界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash) [AI 网关文档](docs/AI网关.md) · [OCI 调用者指纹评估](docs/OCI调用者指纹评估.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
</div> </div>
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。 本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
## 界面预览 ## 界面预览
@@ -48,11 +46,13 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
| 能力域 | 覆盖范围 | | 能力域 | 覆盖范围 |
| --- | --- | | --- | --- |
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 | | **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、保留 IP、IPv6、VCN、安全列表(规则行内编辑)、引导卷、块存储挂载、对象存储(桶 / 对象、在线预览编辑、PAR 直传分享)与限额查询 |
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 | | **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 | | **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTPAES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 | | **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTPAES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 | | **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*``/ai/*` 实时请求 |
## 运行形态 ## 运行形态
@@ -306,6 +306,10 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash) - 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md) - 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
## 致谢
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
## License ## License
[MIT](LICENSE) [MIT](LICENSE)
+2 -1
View File
@@ -31,7 +31,7 @@ import (
) )
// @title OCI Portal API // @title OCI Portal API
// @version 0.0.1 // @version 0.8.2
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。 // @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
// @BasePath / // @BasePath /
// @securityDefinitions.apikey BearerAuth // @securityDefinitions.apikey BearerAuth
@@ -110,6 +110,7 @@ func run() error {
} }
ociClient := oci.NewCachedClient(oci.NewClient()) ociClient := oci.NewCachedClient(oci.NewClient())
ociConfigs := service.NewOciConfigService(db, cipher, ociClient) ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
defer ociConfigs.Stop()
settings := service.NewSettingService(db, cipher) settings := service.NewSettingService(db, cipher)
settings.SetEnvPublicURL(cfg.PublicURL) settings.SetEnvPublicURL(cfg.PublicURL)
if err := settings.ReloadSecurity(context.Background()); err != nil { if err := settings.ReloadSecurity(context.Background()); err != nil {
+22 -8
View File
@@ -196,7 +196,9 @@ Codex 工具兼容现状:
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。 > 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在: 该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的 `70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。2026-07-21
再次复测(Chicago 签名路径)仍未修复:`70,463 B` 两次均在数个推理 delta 后纯
EOF,同时段 `59,651 B` 对照正常 `completed`。本文及设置页中的
`KB` 均按 `1024 B` 计算。 `KB` 均按 `1024 B` 计算。
| 协议 | 网关保护 | 客户端表现 | | 协议 | 网关保护 | 客户端表现 |
@@ -221,13 +223,25 @@ Responses 合成的最小事件序列为:`response.created` →
### `multi-agent` 加密推理内容流式断流 ### `multi-agent` 加密推理内容流式断流
> [!WARNING] > [!WARNING]
> 当 `multi-agent` 请求同时启用 `stream: true` 与 > `xai.grok-4.20-multi-agent` 请求同时启用 `stream: true` 与
> `include: ["reasoning.encrypted_content"]` 时,上游可能在序列化大体量 > `include: ["reasoning.encrypted_content"]` 时,上游高概率在序列化大体量
> `encrypted_content` 事件期间静默断开连接,不返回 `error` 或终态事件。 > `encrypted_content` 事件期间静默断开连接,不返回 `error` 或终态事件。
> > 断点常见于 `output_index: 8` 附近(第三次搜索结束后的大加密推理块),
> 已复现场景中的断点位于 `output_index: 8` 附近:第三次搜索结束后,上游准备 > 也观测到更早(推理起始阶段)与更晚(15 块之后的文本输出阶段)断开。
> 发送较大的加密推理块时连接被中止。`multi-agent` 产生的加密推理内容体积较大,
> 因而更容易暴露该上游流式序列化缺陷。 2026-07-21 复测仍存在,并进一步定界:
- 网关(Phoenix 渠道 API Key)四次全断:两次断于 `output_index: 8` 搜索阶段
(此前 8 块加密内容累计约 `364 KB`),一次收满 15 块约 `762 KB` 后断于文本
输出;Chicago 签名直发同样可断(亦有一次仅 2 块 `35 KB` 的小规模会话正常
完成)——与区域、鉴权方式无关,与加密块规模相关。
- 该模型单块 `encrypted_content``47 KB`,比 `xai.grok-4.3`(约
`2-10 KB`)大一个量级;`grok-4.3` + `web_search` + 同 `include` 在累计
`148 KB` 加密内容、`output_index: 15` 下流式完整——断流特定于
`multi-agent` 模型的大加密块序列化。
- 对照:同请求仅改 `include: ["web_search_call.action.sources"]`(不含加密
推理)正常完成。规避方式:该模型流式时不请求
`reasoning.encrypted_content`
### ZDR 与文件输入 ### ZDR 与文件输入
+117
View File
@@ -0,0 +1,117 @@
<a id="top"></a>
<div align="center">
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
# OCI 调用者指纹评估
**OCI Portal 请求暴露面与多租户关联风险**
![OCI SDK](https://img.shields.io/badge/OCI-Go%20SDK-F80000?logo=oracle&logoColor=white)
![Auth](https://img.shields.io/badge/Auth-API%20Key-2563EB)
![Scope](https://img.shields.io/badge/Scope-Multi--tenant-0F9D58)
[结论速览](#summary) · [请求暴露面](#request-signals) · [项目指纹](#portal-signals) · [关联评估](#assessment) · [核验依据](#evidence)
</div>
> [!NOTE]
> 本文面向架构与隐私评估,说明 OCI Portal 调用 OCI Public API 时 Oracle
> 可见的信息及跨租户关联风险。结论基于当前源码,不代表 Oracle 实际采用的
> 风控规则。
<a id="summary"></a>
## 结论速览
> [!IMPORTANT]
> OCI Portal 的每次 API Key 请求都会在签名的 `keyId` 中携带
> `tenancy OCID / user OCID / fingerprint`。这能准确识别云身份,但不能单凭
> 一次请求证明调用方是 OCI Portal。
| 问题 | 结论 |
| --- | --- |
| Oracle 能识别单次请求的租户和用户吗? | **能。**签名身份、源 IP、目标服务、操作与时间均可见 |
| 通用 SDK 请求头能识别 OCI Portal 吗? | **不能。**默认 User-Agent、签名算法和 Go TLS 特征被大量客户端共享 |
| Oracle 能关联多个租户共用同一实例吗? | **具备能力。**需组合出口 IP、资源元数据、固定文本和调用序列 |
| 外部第三方能完成同等关联吗? | **通常不能。**其无法取得 Oracle 网关日志和租户内部资源元数据 |
<a id="request-signals"></a>
## 单次请求暴露面
当前项目统一通过 `common.NewRawConfigurationProvider` 使用 API Key
```text
Authorization: Signature version="1",
keyId="<tenancyOCID>/<userOCID>/<fingerprint>",
algorithm="rsa-sha256", ...
```
| 信号 | 默认形态 | 区分度 |
| --- | --- | :---: |
| 签名身份 | `tenancy / user / fingerprint` 明文位于 `keyId` | 身份强,软件弱 |
| User-Agent | 大多数 SDK 请求为 `Oracle-GoSDK/<版本> (...)` | 弱 |
| SDK 客户端信息 | SDK 生成的服务请求通常带 `opc-client-info: Oracle-GoSDK/<版本>` | 弱 |
| 请求元数据 | `Date``Host``request-target`;写请求另含 body 摘要 | 通用 |
| 网络特征 | 源 IP、TLS / ALPN、HTTP/2 行为 | 单次弱,跨租户组合后增强 |
| OCI 遥测头 | 显式启用 `OCI_INCLUDE_REQUEST_TELEMETRY_DATA=true` 时,SDK 操作可写 service / operation;直通请求未必携带 | 可选 |
项目没有构造 Instance Principal、Resource Principal 或 Session Token
鉴权,也没有主动覆盖 User-Agent 或为 `opc-request-id` 设置固定前缀。
`opc-client-info` 由 SDK 请求构造器写入,不含 OCI Portal 标记;手工构造的
请求可能不带该字段。少量手工签名请求绕过 `BaseClient.prepareRequest`
使用 Go `net/http` 的默认 User-Agent。
<a id="portal-signals"></a>
## OCI Portal 增量指纹
| 信号 | 当前行为 | 关联强度 |
| --- | --- | :---: |
| 出口 IP | 无租户级或全局代理时,走部署环境默认出口 / NAT | **强旁证(命中时)** |
| 日志回传资源名 | 按 tenancy 派生为 `<8hex>-audit*`,描述使用中性文案 | 弱 |
| GenAI Responses 请求头 | 同时写入 `CompartmentId``opc-compartment-id` | 中 / 弱 |
| API 调用序列 | 测活、账户画像、区域与资源查询存在稳定组合 | 辅助 |
SDK 默认 User-Agent、`opc-client-info` 与固定签名格式只说明“使用 OCI Go
SDK”,不是项目专属信号。
<a id="assessment"></a>
## 多租户关联评估
| 观察视角 | 结论 | 主要依据 |
| --- | --- | --- |
| Oracle 内部,跨租户数据 | **中** | 固定文本、出口 IP 与调用序列可组合比对 |
| Oracle 内部,单租户数据 | **低** | 能识别实现痕迹,但难证明多个租户共用同一实例 |
| 仅看 SDK 请求头 | **低** | 可识别云身份和 SDK 类型,不能可靠识别具体应用 |
| 外部第三方 | **通常不可行** | 缺少 OCI 网关日志、Sign-on Policy 与租户资源元数据 |
这些等级表达的是“可关联性”,不是 Oracle 已执行关联或据此采取处置。
同一出口 IP 也可能来自 NAT、代理或共享基础设施,必须与其他信号联合判断。
<a id="evidence"></a>
## 核验依据
### OCI 与 SDK
- [OCI Request Signatures](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm)
- [OCI API Signing Key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm)
- [OCI Go SDK client.gov65.121.0](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go)
- [OCI Go SDK http_signer.gov65.121.0](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go)
- [OCI Go SDK configuration.gov65.121.0](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/configuration.go)
### 项目源码
- [API Key provider](../internal/oci/client.go#L237-L246)
- [手工签名请求](../internal/oci/account.go#L83-L97)
- [租户出站代理](../internal/oci/proxyhttp.go#L45-L65)
- [日志回传资源命名](../internal/oci/logrelay_names.go#L20-L55)
- [GenAI Responses 请求头](../internal/oci/genai_responses.go#L27-L38)
- [MFA justification](../internal/oci/signon.go#L223-L236)
- [审计与日志回传约束](../.trellis/spec/backend/oci-audit.md)
<p align="right"><a href="#top">返回顶部 ↑</a></p>
+2040 -18
View File
File diff suppressed because it is too large Load Diff
+2040 -18
View File
File diff suppressed because it is too large Load Diff
+1321 -18
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/oracle/oci-go-sdk/v65 v65.121.0 github.com/oracle/oci-go-sdk/v65 v65.121.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
+2 -2
View File
@@ -119,8 +119,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw= github.com/oracle/oci-go-sdk/v65 v65.121.1 h1:nXrE3r1qmBULT6Zp2koM2bJGDvsFtMX/be/NV9MS3Bw=
github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/oracle/oci-go-sdk/v65 v65.121.1/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+2 -2
View File
@@ -141,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
// //
// @Summary 分页查询内容日志 // @Summary 分页查询内容日志
// @Tags AI 管理 // @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiCallLog] // @Success 200 {object} pagedResponse[model.AiContentLog]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-content-logs [get] // @Router /api/v1/ai-content-logs [get]
func (h *aiAdminHandler) listContentLogs(c *gin.Context) { func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
@@ -400,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
// @Summary AI 调用日志列表 // @Summary AI 调用日志列表
// @Tags AI 管理 // @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiContentLog] // @Success 200 {object} pagedResponse[model.AiCallLog]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-logs [get] // @Router /api/v1/ai-logs [get]
func (h *aiAdminHandler) listLogs(c *gin.Context) { func (h *aiAdminHandler) listLogs(c *gin.Context) {
+28 -1
View File
@@ -10,7 +10,8 @@ import (
// ---- 实例 IP ---- // ---- 实例 IP ----
// @Summary ---- 实例 IP ---- // @Summary 更换实例主网卡临时公网 IP
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
// @Tags 计算 // @Tags 计算
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId" // @Param instanceId path string true "instanceId"
@@ -35,6 +36,32 @@ func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"publicIp": ip}) c.JSON(http.StatusOK, gin.H{"publicIp": ip})
} }
// @Summary 更换 VNIC 临时公网 IP
// @Description 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新临时 IP
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param vnicId path string true "VNIC OCID"
// @Param body body object true "请求体:region"
// @Success 200 {object} publicIpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/change-public-ip [post]
func (h *ociConfigHandler) changeVnicPublicIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
}
_ = c.ShouldBindJSON(&req)
ip, err := h.svc.ChangeVnicPublicIP(c.Request.Context(), id, req.Region, c.Param("vnicId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"publicIp": ip})
}
// @Summary 实例添加 IPv6 地址 // @Summary 实例添加 IPv6 地址
// @Tags 计算 // @Tags 计算
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
+15 -9
View File
@@ -65,7 +65,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
// @Summary 激活两步验证 // @Summary 激活两步验证
// @Tags 认证 // @Tags 认证
// @Param body body object true "{code: 6 位验证码}" // @Param body body object true "{code: 6 位验证码}"
// @Success 204 "已启用" // @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
// @Success 204 "已启用但新 token 签发失败,需重新登录"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/auth/totp/activate [post] // @Router /api/v1/auth/totp/activate [post]
func (h *authxHandler) totpActivate(c *gin.Context) { func (h *authxHandler) totpActivate(c *gin.Context) {
@@ -93,7 +94,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
// @Summary 停用两步验证 // @Summary 停用两步验证
// @Tags 认证 // @Tags 认证
// @Param body body object true "{password 或 code 任一确认}" // @Param body body object true "{password 或 code 任一确认}"
// @Success 204 "已停用" // @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
// @Success 204 "已停用但新 token 签发失败,需重新登录"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/auth/totp/disable [post] // @Router /api/v1/auth/totp/disable [post]
func (h *authxHandler) totpDisable(c *gin.Context) { func (h *authxHandler) totpDisable(c *gin.Context) {
@@ -170,7 +172,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
// @Summary 修改登录凭据 // @Summary 修改登录凭据
// @Tags 认证 // @Tags 认证
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)" // @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
// @Success 204 "已更新,请重新登录" // @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
// @Success 204 "已更新但新 token 签发失败,需重新登录"
// @Failure 401 {object} errorResponse "当前密码错误" // @Failure 401 {object} errorResponse "当前密码错误"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/auth/credentials [put] // @Router /api/v1/auth/credentials [put]
@@ -202,7 +205,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
// @Summary 密码登录开关 // @Summary 密码登录开关
// @Tags 认证 // @Tags 认证
// @Param body body object true "{disabled: bool}" // @Param body body object true "{disabled: bool}"
// @Success 204 "已保存" // @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
// @Success 204 "已保存但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "未绑定外部身份" // @Failure 409 {object} errorResponse "未绑定外部身份"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/auth/password-login [put] // @Router /api/v1/auth/password-login [put]
@@ -375,7 +379,8 @@ func (h *authxHandler) identities(c *gin.Context) {
// @Summary 解绑外部身份 // @Summary 解绑外部身份
// @Tags 认证 // @Tags 认证
// @Param id path int true "身份 ID" // @Param id path int true "身份 ID"
// @Success 204 "已解绑" // @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "最后一个身份不可解绑" // @Failure 409 {object} errorResponse "最后一个身份不可解绑"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/auth/identities/{id} [delete] // @Router /api/v1/auth/identities/{id} [delete]
@@ -414,14 +419,15 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
c.JSON(http.StatusOK, view) c.JSON(http.StatusOK, view)
} }
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。 // updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
// secret 传空串清除、缺省沿用。
// //
// @Summary 保存 OAuth provider 配置 // @Summary 部分更新 OAuth provider 配置
// @Tags 设置 // @Tags 设置
// @Param body body service.UpdateOAuthInput true "provider 配置" // @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
// @Success 200 {object} service.OAuthProvidersView // @Success 200 {object} service.OAuthProvidersView
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/settings/oauth [put] // @Router /api/v1/settings/oauth [patch]
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) { func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
var req service.UpdateOAuthInput var req service.UpdateOAuthInput
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
_ "oci-portal/internal/oci" // swagger 注解引用
)
// payInvoiceRequest 是付款请求体;email 接收 OSP 付款回执。
type payInvoiceRequest struct {
Email string `json:"email" binding:"required"`
}
// @Summary 发票列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param year query int false "自然年过滤(按开票时间);缺省全量"
// @Success 200 {array} oci.Invoice
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices [get]
func (h *ociConfigHandler) invoices(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
year, _ := strconv.Atoi(c.Query("year"))
list, err := h.svc.Invoices(c.Request.Context(), id, year)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, list)
}
// @Summary 发票费用明细
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Success 200 {array} oci.InvoiceLine
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/lines [get]
func (h *ociConfigHandler) invoiceLines(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
lines, err := h.svc.InvoiceLines(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, lines)
}
// @Summary 发票 PDF 下载
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param number query string false "发票号(用作下载文件名)"
// @Success 200 {file} binary "PDF 原文"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pdf [get]
func (h *ociConfigHandler) invoicePdf(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
data, err := h.svc.InvoicePdf(c.Request.Context(), id, c.Param("internalId"))
if err != nil {
respondError(c, err)
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", pdfFileName(c.Query("number"), c.Param("internalId"))))
c.Data(http.StatusOK, "application/pdf", data)
}
// pdfFileName 生成下载文件名:优先发票号,剔除引号/路径分隔等不安全字符。
func pdfFileName(number, internalID string) string {
name := strings.TrimSpace(number)
if name == "" {
name = internalID
}
name = strings.Map(func(r rune) rune {
if r == '"' || r == '/' || r == '\\' || r < 0x20 {
return '_'
}
return r
}, name)
return name + ".pdf"
}
// @Summary 支付发票
// @Tags 账单
// @Param id path int true "配置 ID"
// @Param internalId path string true "发票内部 ID"
// @Param body body payInvoiceRequest true "回执邮箱"
// @Success 200 {object} map[string]string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/invoices/{internalId}/pay [post]
func (h *ociConfigHandler) payInvoice(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req payInvoiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.PayInvoice(c.Request.Context(), id, c.Param("internalId"), req.Email); err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"status": "submitted"})
}
// @Summary 付款方式列表
// @Tags 账单
// @Param id path int true "配置 ID"
// @Success 200 {array} oci.PaymentMethod
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/payment-methods [get]
func (h *ociConfigHandler) paymentMethods(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
methods, err := h.svc.PaymentMethods(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, methods)
}
+138 -3
View File
@@ -1,13 +1,121 @@
package api package api
import ( import (
"errors"
"io"
"log"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"oci-portal/internal/oci" "oci-portal/internal/oci"
"oci-portal/internal/service"
) )
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
type idpIconResponse struct {
URL string `json:"url"`
FileName string `json:"fileName"`
}
type idpSetupWarning struct {
Code string `json:"code"`
Message string `json:"message"`
ResourceCreated bool `json:"resourceCreated"`
RequestID string `json:"requestId"`
}
type createIdpResponse struct {
oci.IdentityProviderInfo
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
}
// @Summary 上传身份提供商图标
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
// @Tags 租户 IAM
// @Accept multipart/form-data
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
// @Success 200 {object} idpIconResponse
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
// @Failure 413 {object} map[string]string "文件超过 1MB"
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
status, msg := iconFormStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
defer file.Close()
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
if err != nil {
status, msg := iconFormStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
if err != nil {
respondIdpIconErr(c, err)
return
}
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
}
// @Summary 删除身份提供商图标
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
// @Success 204 "无内容"
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
respondIdpIconErr(c, err)
return
}
c.Status(http.StatusNoContent)
}
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
func iconFormStatus(err error) (int, string) {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
}
return http.StatusBadRequest, "缺少文件字段 file"
}
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
func respondIdpIconErr(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrIdpIconTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
case errors.Is(err, service.ErrIdpIconBadType):
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
case errors.Is(err, service.ErrIdpIconEmpty):
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
case errors.Is(err, service.ErrIdpIconBadName):
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
default:
respondError(c, err)
}
}
// ---- FederationSAML IdP 与 sign-on 免 MFA ---- // ---- FederationSAML IdP 与 sign-on 免 MFA ----
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」, // createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
@@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
} }
// @Summary 创建身份提供商(SAML) // @Summary 创建身份提供商(SAML)
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createIdpRequest true "请求体" // @Param body body createIdpRequest true "请求体"
// @Success 201 {object} oci.IdentityProviderInfo // @Success 201 {object} createIdpResponse
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [post] // @Router /api/v1/oci-configs/{id}/identity-providers [post]
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) { func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
@@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true), JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
JitMapEmail: req.JitMapEmail, JitMapEmail: req.JitMapEmail,
}) })
if err != nil { respondCreateIdpResult(c, idp, err)
}
func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) {
if err == nil {
c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp})
return
}
var partial *oci.PartialIdentityProviderCreateError
if !errors.As(err, &partial) {
respondError(c, err) respondError(c, err)
return return
} }
c.JSON(http.StatusCreated, idp) requestID := logPartialIdpCreate(c, partial)
c.JSON(http.StatusCreated, createIdpResponse{
IdentityProviderInfo: partial.IdentityProvider,
SetupWarning: &idpSetupWarning{
Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查",
ResourceCreated: true, RequestID: requestID,
},
})
}
func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string {
requestID := newRequestID()
detail := errors.Unwrap(partial)
if detail == nil {
detail = partial
}
log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail)
return requestID
} }
// @Summary 激活身份提供商 // @Summary 激活身份提供商
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
t.Helper()
router, auth, logs, db := newTestRouterDB(t)
id := seedIconConfig(t, db)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
return router, token, id, logs
}
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
t.Helper()
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
if err != nil {
t.Fatalf("encrypt key: %v", err)
}
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
return cfg.ID
}
func iconSVG(t *testing.T, size int) []byte {
t.Helper()
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
if len(data) > size {
t.Fatalf("svg size %d exceeds target %d", len(data), size)
}
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
}
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("create form file: %v", err)
}
_, _ = part.Write(data)
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
cases := []struct {
name, fileName string
data []byte
want int
}{
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
{"empty", "icon.png", nil, http.StatusBadRequest},
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
}
for _, tc := range cases {
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
if rec.Code != tc.want {
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
}
}
}
func TestDeleteIdpIconRoute(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
query := url.Values{"fileName": {fileName}}
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
if rec.Code != http.StatusNoContent {
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
}
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid delete status = %d, want 400", rec.Code)
}
}
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
responder(ctx, err)
if rec.Code != want {
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
}
}
func TestIdpIconErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"empty icon", service.ErrIdpIconEmpty, 400},
{"bad icon name", service.ErrIdpIconBadName, 400},
{"large icon", service.ErrIdpIconTooLarge, 413},
{"bad icon type", service.ErrIdpIconBadType, 415},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
}
}
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
}
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
}
}
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
partial := &oci.PartialIdentityProviderCreateError{
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
}
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
warning, ok := body["setupWarning"].(map[string]interface{})
if rec.Code != http.StatusCreated || !ok {
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
}
requestID, _ := warning["requestId"].(string)
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
t.Errorf("warning = %#v, want stable partial-create contract", warning)
}
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
}
}
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
respondCreateIdpResult(ctx, idp, err)
body := map[string]interface{}{}
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
return rec, body
}
func TestPARErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
}{
{"bad PAR type", service.ErrPARInvalidAccessType},
{"bad PAR expiry", service.ErrPARInvalidExpiration},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
}
}
func TestCreatePARValidationReturns400(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
cases := []struct {
name, body string
}{
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
}
for _, tc := range cases {
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
}
}
}
+2
View File
@@ -44,6 +44,7 @@ type createInstanceRequest struct {
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"` BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网 SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
AssignPublicIP bool `json:"assignPublicIp"` AssignPublicIP bool `json:"assignPublicIp"`
ReservedPublicIPID string `json:"reservedPublicIpId"` // 非空时不分配临时 IP,实例就绪后自动绑定该保留 IP
AssignIpv6 bool `json:"assignIpv6"` AssignIpv6 bool `json:"assignIpv6"`
SSHPublicKey string `json:"sshPublicKey"` SSHPublicKey string `json:"sshPublicKey"`
RootPassword string `json:"rootPassword"` RootPassword string `json:"rootPassword"`
@@ -118,6 +119,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB, BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
SubnetID: req.SubnetID, SubnetID: req.SubnetID,
AssignPublicIP: req.AssignPublicIP, AssignPublicIP: req.AssignPublicIP,
ReservedPublicIPID: req.ReservedPublicIPID,
AssignIpv6: req.AssignIpv6, AssignIpv6: req.AssignIpv6,
SSHPublicKey: req.SSHPublicKey, SSHPublicKey: req.SSHPublicKey,
RootPassword: req.RootPassword, RootPassword: req.RootPassword,
+464
View File
@@ -0,0 +1,464 @@
package api
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
// ---- 对象存储 ----
// @Summary 对象存储 namespace
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param region query string false "区域"
// @Success 200 {object} map[string]string
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/object-storage/namespace [get]
func (h *ociConfigHandler) osNamespace(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
ns, err := h.svc.ObjectStorageNamespace(c.Request.Context(), id, c.Query("region"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"namespace": ns})
}
// @Summary 存储桶列表
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param region query string false "区域"
// @Param compartmentId query string false "区间 OCID,空为生效区间"
// @Success 200 {array} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets [get]
func (h *ociConfigHandler) listBuckets(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
buckets, err := h.svc.Buckets(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, buckets)
}
type createBucketRequest struct {
Region string `json:"region"`
CompartmentID string `json:"compartmentId"`
Name string `json:"name" binding:"required"`
PublicRead bool `json:"publicRead"`
StorageTier string `json:"storageTier"` // Standard / Archive
VersioningOn bool `json:"versioningOn"`
}
// @Summary 创建存储桶
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param body body createBucketRequest true "请求体"
// @Success 201 {object} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets [post]
func (h *ociConfigHandler) createBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req createBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bucket, err := h.svc.CreateBucket(c.Request.Context(), id, req.Region, oci.CreateBucketInput{
Name: req.Name, CompartmentID: req.CompartmentID,
PublicRead: req.PublicRead, StorageTier: req.StorageTier, VersioningOn: req.VersioningOn,
})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, bucket)
}
type updateBucketRequest struct {
Region string `json:"region"`
PublicRead *bool `json:"publicRead"`
VersioningOn *bool `json:"versioningOn"`
}
// @Summary 更新存储桶(可见性/版本控制)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body updateBucketRequest true "请求体"
// @Success 200 {object} oci.Bucket
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [put]
func (h *ociConfigHandler) updateBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req updateBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bucket, err := h.svc.UpdateBucket(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.UpdateBucketInput{
PublicRead: req.PublicRead, VersioningOn: req.VersioningOn,
})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, bucket)
}
// @Summary 删除存储桶(非空桶后台清空后删除)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Success 200 {object} map[string]bool "queued=true 表示已转后台清空删除"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket} [delete]
func (h *ociConfigHandler) deleteBucket(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
queued, err := h.svc.DeleteBucket(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"queued": queued})
}
// @Summary 对象列表(前缀模式分页)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param prefix query string false "前缀(虚拟目录)"
// @Param startWith query string false "上页 nextStartWith 游标"
// @Param limit query int false "每页数量,默认 100"
// @Success 200 {object} oci.ListObjectsResult
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [get]
func (h *ociConfigHandler) listObjects(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
result, err := h.svc.Objects(c.Request.Context(), id, c.Query("region"),
c.Param("bucket"), c.Query("prefix"), c.Query("startWith"), limit)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
// @Summary 删除对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 204 "删除成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects [delete]
func (h *ociConfigHandler) deleteObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteObject(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 重命名对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body object true "请求体:region、source、newName"
// @Success 204 "重命名成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/rename [post]
func (h *ociConfigHandler) renameObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
Source string `json:"source" binding:"required"`
NewName string `json:"newName" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.RenameObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Source, req.NewName)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 取回 Archive 对象
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body object true "请求体:region、object、hours(可下载时长,默认 24)"
// @Success 202 "取回已提交,约 1 小时后可下载"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/restore [post]
func (h *ociConfigHandler) restoreObject(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
Object string `json:"object" binding:"required"`
Hours int `json:"hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.RestoreObject(c.Request.Context(), id, req.Region, c.Param("bucket"), req.Object, req.Hours)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusAccepted)
}
// @Summary 对象元数据
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {object} oci.ObjectDetail
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/detail [get]
func (h *ociConfigHandler) objectDetail(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
detail, err := h.svc.ObjectDetail(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, detail)
}
// maxPutContentBody 是保存对象内容的请求体上限(与 service 层 5MB 限制一致)。
const maxPutContentBody = 5 << 20
// respondContentError 对象内容中转专属错误:超限映射 413,其余走通用处理。
func respondContentError(c *gin.Context, err error) {
if errors.Is(err, oci.ErrObjectTooLarge) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "对象超出面板中转大小上限"})
return
}
respondError(c, err)
}
// @Summary 读取对象内容(面板中转)
// @Description 预览/编辑用小文件直读(上限 20MB),不签发 PAR;响应体为原始字节,ETag 经响应头返回
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {string} string "对象原始内容"
// @Failure 413 {object} map[string]string "对象超出中转上限"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [get]
func (h *ociConfigHandler) getObjectContent(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
content, err := h.svc.ObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("object"))
if err != nil {
respondContentError(c, err)
return
}
ct := content.ContentType
if ct == "" {
ct = "application/octet-stream"
}
c.Header("ETag", content.Etag)
c.Data(http.StatusOK, ct, content.Data)
}
// @Summary 保存对象内容(面板中转)
// @Description 文本编辑保存(上限 5MB),请求体为原始字节;If-Match 请求头做并发保护,冲突返回 412
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param object query string true "对象名(完整 key)"
// @Success 200 {object} map[string]string "etag=新 ETag"
// @Failure 412 {object} map[string]string "If-Match 不匹配,对象已被并发修改"
// @Failure 413 {object} map[string]string "请求体超出上限"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/objects/content [put]
func (h *ociConfigHandler) putObjectContent(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxPutContentBody)
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "请求体超出 5MB 上限"})
return
}
etag, err := h.svc.PutObjectContent(c.Request.Context(), id, c.Query("region"), c.Param("bucket"),
c.Query("object"), data, c.ContentType(), c.GetHeader("If-Match"))
if err != nil {
respondContentError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"etag": etag})
}
type createPARRequest struct {
Region string `json:"region"`
Name string `json:"name"`
ObjectName string `json:"objectName"` // 空 = 桶级
AccessType string `json:"accessType" binding:"required"`
ExpiresHours int `json:"expiresHours"`
}
// @Summary 签发临时链接(PAR)
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param body body createPARRequest true "请求体"
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
func (h *ociConfigHandler) createPAR(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req createPARRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
par, err := h.svc.CreatePAR(c.Request.Context(), id, req.Region, c.Param("bucket"), oci.CreatePARInput{
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
})
if err != nil {
respondPARError(c, err)
return
}
c.JSON(http.StatusCreated, par)
}
func respondPARError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrPARInvalidAccessType):
c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"})
case errors.Is(err, service.ErrPARInvalidExpiration):
c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"})
default:
respondError(c, err)
}
}
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
type parPage struct {
Items []oci.PAR `json:"items"`
NextPage string `json:"nextPage"`
}
// @Summary 临时链接列表
// @Description 游标分页:page 传上次响应的 nextPage,nextPage 为空表示已到末页
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param region query string false "区域"
// @Param page query string false "分页游标(上次响应的 nextPage,空取首页)"
// @Param limit query int false "每页条数,默认 100,上限 1000"
// @Success 200 {object} parPage
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [get]
func (h *ociConfigHandler) listPARs(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
items, next, err := h.svc.PARsPage(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("page"), limit)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, parPage{Items: items, NextPage: next})
}
// @Summary 删除临时链接
// @Description all=1 时删除桶内全部 PAR 并返回 {"deleted": n};否则按 parId 删单条
// @Tags 对象存储
// @Param id path int true "配置 ID"
// @Param bucket path string true "桶名"
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
// @Param all query string false "为 1 时删除全部"
// @Param region query string false "区域"
// @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}"
// @Success 204 "按 parId 删单条成功,链接立即失效"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
if c.Query("all") == "1" {
n, err := h.svc.DeleteAllPARs(c.Request.Context(), id, c.Query("region"), c.Param("bucket"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
return
}
err := h.svc.DeletePAR(c.Request.Context(), id, c.Query("region"), c.Param("bucket"), c.Query("parId"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
)
// swag 解析 @Success 里的 oci.ReservedIP 需要本文件持有该包导入
var _ = oci.ReservedIP{}
// ---- 保留公网 IP ----
// @Summary 保留 IP 列表
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param region query string false "区域,空为主区域"
// @Param compartmentId query string false "区间 OCID,空为生效区间"
// @Success 200 {array} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [get]
func (h *ociConfigHandler) listReservedIPs(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
ips, err := h.svc.ReservedIPs(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, ips)
}
// @Summary 创建保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:region、compartmentId、displayName"
// @Success 201 {object} oci.ReservedIP
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips [post]
func (h *ociConfigHandler) createReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
CompartmentID string `json:"compartmentId"`
DisplayName string `json:"displayName"`
}
_ = c.ShouldBindJSON(&req)
ip, err := h.svc.CreateReservedIP(c.Request.Context(), id, req.Region, req.CompartmentID, req.DisplayName)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, ip)
}
// @Summary 绑定/解绑保留 IP
// @Description vnicId 非空时绑到该网卡,否则绑 instanceId 主网卡(两者都空为解绑);目标已有公网 IP 时自动释放/解绑
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param body body object true "请求体:region、instanceId、vnicId"
// @Success 204 "绑定成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [put]
func (h *ociConfigHandler) assignReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Region string `json:"region"`
InstanceID string `json:"instanceId"`
VnicID string `json:"vnicId"`
}
_ = c.ShouldBindJSON(&req)
err := h.svc.AssignReservedIP(c.Request.Context(), id, req.Region, c.Param("publicIpId"), req.InstanceID, req.VnicID)
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 删除保留 IP
// @Tags 网络
// @Param id path int true "配置 ID"
// @Param publicIpId path string true "保留 IP OCID"
// @Param region query string false "区域"
// @Success 204 "删除成功"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/reserved-ips/{publicIpId} [delete]
func (h *ociConfigHandler) deleteReservedIP(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteReservedIP(c.Request.Context(), id, c.Query("region"), c.Param("publicIpId"))
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+7
View File
@@ -34,6 +34,13 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
registerSettings(secured, settings, notifier, systemLogs, proxies) registerSettings(secured, settings, notifier, systemLogs, proxies)
registerTasksAndLogs(secured, tasks, logEvents) registerTasksAndLogs(secured, tasks, logEvents)
registerOci(secured, ociConfigs) registerOci(secured, ociConfigs)
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
// 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413
uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
registerOciUploads(uploads, ociConfigs)
// 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行
contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
registerOciContentUploads(contentUploads, ociConfigs)
registerAiAdmin(secured, aiGateway) registerAiAdmin(secured, aiGateway)
registerSwagger(r) registerSwagger(r)
+8
View File
@@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
return oci.SubscriptionDetail{}, nil return oci.SubscriptionDetail{}, nil
} }
func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
return "https://images.example/" + fileName, "images/generated/" + fileName, nil
}
func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error {
return nil
}
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) { func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
t.Helper() t.Helper()
r, auth, systemLogs, _ := newTestRouterDB(t) r, auth, systemLogs, _ := newTestRouterDB(t)
+1 -1
View File
@@ -35,5 +35,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
secured.DELETE("/auth/identities/:id", ax.unbindIdentity) secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
secured.POST("/auth/revoke-sessions", ax.revokeSessions) secured.POST("/auth/revoke-sessions", ax.revokeSessions)
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) }) secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) }) secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
} }
+54
View File
@@ -16,6 +16,7 @@ func registerOci(secured *gin.RouterGroup, ociConfigs *service.OciConfigService)
registerOciCompute(secured, h) registerOciCompute(secured, h)
registerOciNetwork(secured, h) registerOciNetwork(secured, h)
registerOciStorage(secured, h) registerOciStorage(secured, h)
registerOciObjectStorage(secured, h)
registerOciCost(secured, h) registerOciCost(secured, h)
registerOciTenantIAM(secured, h) registerOciTenantIAM(secured, h)
} }
@@ -37,6 +38,13 @@ func registerOciConfig(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/limits/services", h.limitServices) g.GET("/oci-configs/:id/limits/services", h.limitServices)
g.GET("/oci-configs/:id/subscriptions", h.subscriptions) g.GET("/oci-configs/:id/subscriptions", h.subscriptions)
g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail) g.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
// 账单:发票与付款方式(OSP Gateway,仅主区域)
g.GET("/oci-configs/:id/invoices", h.invoices)
g.GET("/oci-configs/:id/invoices/:internalId/lines", h.invoiceLines)
g.GET("/oci-configs/:id/invoices/:internalId/pdf", h.invoicePdf)
g.POST("/oci-configs/:id/invoices/:internalId/pay", h.payInvoice)
g.GET("/oci-configs/:id/payment-methods", h.paymentMethods)
g.GET("/oci-configs/:id/shapes", h.shapes) g.GET("/oci-configs/:id/shapes", h.shapes)
g.GET("/oci-configs/:id/images", h.images) g.GET("/oci-configs/:id/images", h.images)
g.GET("/oci-configs/:id/images/:imageId", h.getImage) g.GET("/oci-configs/:id/images/:imageId", h.getImage)
@@ -61,6 +69,7 @@ func registerOciCompute(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic) g.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic) g.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
g.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6) g.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6)
g.POST("/oci-configs/:id/vnics/:vnicId/change-public-ip", h.changeVnicPublicIP)
g.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic) g.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic)
} }
@@ -77,6 +86,10 @@ func registerOciNetwork(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet) g.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet) g.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet) g.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
g.GET("/oci-configs/:id/reserved-ips", h.listReservedIPs)
g.POST("/oci-configs/:id/reserved-ips", h.createReservedIP)
g.PUT("/oci-configs/:id/reserved-ips/:publicIpId", h.assignReservedIP)
g.DELETE("/oci-configs/:id/reserved-ips/:publicIpId", h.deleteReservedIP)
g.GET("/oci-configs/:id/security-lists", h.listSecurityLists) g.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
g.POST("/oci-configs/:id/security-lists", h.createSecurityList) g.POST("/oci-configs/:id/security-lists", h.createSecurityList)
g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList) g.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
@@ -100,6 +113,35 @@ func registerOciStorage(g *gin.RouterGroup, h *ociConfigHandler) {
g.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume) g.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume)
} }
// registerOciObjectStorage 对象存储:namespace、桶、对象、临时链接。
func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/object-storage/namespace", h.osNamespace)
g.GET("/oci-configs/:id/buckets", h.listBuckets)
g.POST("/oci-configs/:id/buckets", h.createBucket)
g.PUT("/oci-configs/:id/buckets/:bucket", h.updateBucket)
g.DELETE("/oci-configs/:id/buckets/:bucket", h.deleteBucket)
g.GET("/oci-configs/:id/buckets/:bucket/objects", h.listObjects)
g.DELETE("/oci-configs/:id/buckets/:bucket/objects", h.deleteObject)
g.POST("/oci-configs/:id/buckets/:bucket/objects/rename", h.renameObject)
g.POST("/oci-configs/:id/buckets/:bucket/objects/restore", h.restoreObject)
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
// 小文件中转:预览/编辑直读直写,不签发 PAR
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
}
// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开
// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。
// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。
func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
h := &ociConfigHandler{svc: ociConfigs}
g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon)
g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon)
}
// registerOciCost 成本快照。 // registerOciCost 成本快照。
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) { func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/costs", h.costs) g.GET("/oci-configs/:id/costs", h.costs)
@@ -118,6 +160,10 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword) g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa) g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys) g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
g.GET("/oci-configs/:id/users/:userId/api-keys", h.listTenantUserApiKeys)
g.POST("/oci-configs/:id/users/:userId/api-keys", h.addTenantUserApiKey)
g.DELETE("/oci-configs/:id/users/:userId/api-keys/:fingerprint", h.deleteTenantUserApiKey)
g.POST("/oci-configs/:id/activate-api-key", h.activateApiKey)
g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients) g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients) g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies) g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
@@ -133,3 +179,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption) g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption) g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
} }
// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限);
// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB
// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。
func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
h := &ociConfigHandler{svc: ociConfigs}
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
}
+1 -1
View File
@@ -26,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.GET("/settings/task", st.getTaskSettings) secured.GET("/settings/task", st.getTaskSettings)
secured.PUT("/settings/task", st.updateTaskSettings) secured.PUT("/settings/task", st.updateTaskSettings)
secured.GET("/settings/security", st.getSecurity) secured.GET("/settings/security", st.getSecurity)
secured.PUT("/settings/security", st.updateSecurity) secured.PATCH("/settings/security", st.updateSecurity)
px := &proxyHandler{svc: proxies} px := &proxyHandler{svc: proxies}
secured.GET("/proxies", px.list) secured.GET("/proxies", px.list)
+6 -5
View File
@@ -316,16 +316,17 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
c.JSON(http.StatusOK, view) c.JSON(http.StatusOK, view)
} }
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。 // updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
// 越界或非法返回 400,保存后立即生效。
// //
// @Summary 保存安全设置并返回最新值 // @Summary 部分更新安全设置并返回最新值
// @Tags 设置 // @Tags 设置
// @Param body body service.SecuritySettings true "请求体" // @Param body body service.SecurityPatch true "出现的字段才会被更新"
// @Success 200 {object} service.SecuritySettings // @Success 200 {object} service.SecuritySettings
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/settings/security [put] // @Router /api/v1/settings/security [patch]
func (h *settingsHandler) updateSecurity(c *gin.Context) { func (h *settingsHandler) updateSecurity(c *gin.Context) {
var req service.SecuritySettings var req service.SecurityPatch
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
+5
View File
@@ -157,6 +157,11 @@ type deletedApiKeysResponse struct {
DeletedApiKeys int `json:"deletedApiKeys"` DeletedApiKeys int `json:"deletedApiKeys"`
} }
// userApiKeysResponse 是用户 API 签名 key 列表。
type userApiKeysResponse struct {
Items []service.UserApiKeyInfo `json:"items"`
}
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。 // createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
type createInstancesResponse struct { type createInstancesResponse struct {
Instances []oci.Instance `json:"instances"` Instances []oci.Instance `json:"instances"`
+16 -2
View File
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
// @Summary 更新任务 // @Summary 更新任务
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "任务 ID" // @Param id path int true "任务 ID"
// @Param body body updateTaskRequest true "可更新字段" // @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
// @Success 200 {object} model.Task // @Success 200 {object} model.Task
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/tasks/{id} [put] // @Router /api/v1/tasks/{id} [put]
func (h *taskHandler) update(c *gin.Context) { func (h *taskHandler) update(c *gin.Context) {
@@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
Status: req.Status, Status: req.Status,
}) })
if err != nil { if err != nil {
respondError(c, err) respondUpdateTaskErr(c, err)
return return
} }
c.JSON(http.StatusOK, task) c.JSON(http.StatusOK, task)
} }
// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。
func respondUpdateTaskErr(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrTaskConflict):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrSnatchTargetTooLow):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
respondError(c, err)
}
}
// @Summary 删除任务 // @Summary 删除任务
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "任务 ID" // @Param id path int true "任务 ID"
+100
View File
@@ -38,6 +38,11 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
// @Summary 配置成本快照 // @Summary 配置成本快照
// @Tags 成本 // @Tags 成本
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param startTime query string false "起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤)"
// @Param endTime query string false "结束时间 RFC3339,缺省为当前时刻"
// @Param granularity query string false "HOURLY / DAILY / MONTHLY,缺省 DAILY"
// @Param queryType query string false "COST / USAGE,缺省 COST"
// @Param groupBy query string false "分组维度,单维或复合(如 service,skuName),缺省 service"
// @Success 200 {array} oci.CostItem // @Success 200 {array} oci.CostItem
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/costs [get] // @Router /api/v1/oci-configs/{id}/costs [get]
@@ -366,6 +371,101 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted}) c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
} }
// @Summary 列出用户 API Key
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param userId path string true "userId"
// @Success 200 {object} userApiKeysResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [get]
func (h *ociConfigHandler) listTenantUserApiKeys(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
keys, err := h.svc.TenantUserApiKeys(c.Request.Context(), id, c.Param("userId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": keys})
}
// @Summary 为用户新增 API Key
// @Description 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param userId path string true "userId"
// @Success 200 {object} service.CreatedApiKey
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [post]
func (h *ociConfigHandler) addTenantUserApiKey(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
created, err := h.svc.AddTenantUserApiKey(c.Request.Context(), id, c.Param("userId"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, created)
}
// @Summary 删除用户单把 API Key
// @Description 当前配置正在使用的 key 拒删(409),避免面板失联。
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param userId path string true "userId"
// @Param fingerprint path string true "key 指纹"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint} [delete]
func (h *ociConfigHandler) deleteTenantUserApiKey(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
err := h.svc.DeleteTenantUserApiKey(c.Request.Context(), id, c.Param("userId"), c.Param("fingerprint"))
if errors.Is(err, service.ErrCurrentApiKey) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary 启用 API Key 为面板签名凭据
// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:fingerprint 与 privateKey"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/activate-api-key [post]
func (h *ociConfigHandler) activateApiKey(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
Fingerprint string `json:"fingerprint"`
PrivateKey string `json:"privateKey"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Fingerprint == "" || req.PrivateKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint 与 privateKey 必填"})
return
}
if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.Fingerprint, req.PrivateKey); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---- 域通知收件人与密码策略 ---- // ---- 域通知收件人与密码策略 ----
// @Summary ---- 域通知收件人与密码策略 ---- // @Summary ---- 域通知收件人与密码策略 ----
+1
View File
@@ -229,6 +229,7 @@ type CompartmentCache struct {
OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"` OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"`
OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"` OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"`
Name string `gorm:"size:128" json:"name"` Name string `gorm:"size:128" json:"name"`
ParentOCID string `gorm:"size:128" json:"parentId"`
State string `gorm:"size:16" json:"lifecycleState"` State string `gorm:"size:16" json:"lifecycleState"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
+320
View File
@@ -0,0 +1,320 @@
package oci
import (
"context"
"fmt"
"io"
"math"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/ospgateway"
)
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
type Invoice struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
Type string `json:"type"`
Status string `json:"status"`
IsPaid bool `json:"isPaid"`
IsPayable bool `json:"isPayable"`
IsPaymentFailed bool `json:"isPaymentFailed"`
Amount float64 `json:"amount"`
AmountDue float64 `json:"amountDue"`
Currency string `json:"currency"`
TimeInvoice *time.Time `json:"timeInvoice"`
TimeDue *time.Time `json:"timeDue"`
}
// InvoiceLine 是发票内一行费用明细。
type InvoiceLine struct {
Product string `json:"product"`
OrderNo string `json:"orderNo"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
Total float64 `json:"total"`
Currency string `json:"currency"`
TimeStart *time.Time `json:"timeStart"`
TimeEnd *time.Time `json:"timeEnd"`
}
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
type PaymentMethod struct {
Method string `json:"method"` // CREDIT_CARD / PAYPAL
CardType string `json:"cardType,omitempty"`
LastDigits string `json:"lastDigits,omitempty"`
NameOnCard string `json:"nameOnCard,omitempty"`
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
Email string `json:"email,omitempty"`
PayerName string `json:"payerName,omitempty"`
BillingAgreement string `json:"billingAgreement,omitempty"`
}
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
info, err := c.ValidateKey(ctx, cred)
if err != nil {
return "", fmt.Errorf("resolve osp home region: %w", err)
}
r, ok := RegionByKey(info.HomeRegionKey)
if !ok {
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
}
if cred.TenancyOCID != "" {
c.homeRegions.Store(cred.TenancyOCID, r.Name)
}
return r.Name, nil
}
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", err
}
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
}
applyProxy(&ic.BaseClient, cred)
ic.SetRegion(region)
return ic, region, nil
}
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
// (按 timeInvoice 窗口过滤),为 0 拉全量。
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
req := ospgateway.ListInvoicesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Limit: common.Int(ospPageLimit),
}
if year > 0 {
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
}
var out []Invoice
for {
resp, err := ic.ListInvoices(ctx, req)
if err != nil {
return nil, fmt.Errorf("list invoices: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoice(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
req.Page = resp.OpcNextPage
}
}
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
const ospPageLimit = 100
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
var out []InvoiceLine
var page *string
for {
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
Page: page,
Limit: common.Int(ospPageLimit),
})
if err != nil {
return nil, fmt.Errorf("list invoice lines: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoiceLine(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
})
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
}
return data, nil
}
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return err
}
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
})
if err != nil {
return fmt.Errorf("pay invoice: %w", err)
}
return nil
}
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return nil, err
}
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return nil, fmt.Errorf("new osp subscription client: %w", err)
}
applyProxy(&sc.BaseClient, cred)
sc.SetRegion(region)
var out []PaymentMethod
var page *string
for {
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
for _, sub := range resp.Items {
out = appendPaymentMethods(out, sub.PaymentOptions)
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
inv := Invoice{
ID: deref(v.InvoiceId),
InternalID: deref(v.InternalInvoiceId),
Number: deref(v.InvoiceNumber),
Type: string(v.InvoiceType),
Status: string(v.InvoiceStatus),
Amount: money(v.InvoiceAmount),
AmountDue: money(v.InvoiceAmountDue),
TimeInvoice: sdkTime(v.TimeInvoice),
TimeDue: sdkTime(v.TimeInvoiceDue),
}
if v.IsPaid != nil {
inv.IsPaid = *v.IsPaid
}
if v.IsPayable != nil {
inv.IsPayable = *v.IsPayable
}
if v.IsPaymentFailed != nil {
inv.IsPaymentFailed = *v.IsPaymentFailed
}
if v.Currency != nil {
inv.Currency = deref(v.Currency.CurrencyCode)
}
return inv
}
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
line := InvoiceLine{
Product: deref(v.Product),
OrderNo: deref(v.OrderNo),
Quantity: money(v.Quantity),
UnitPrice: money(v.NetUnitPrice),
Total: money(v.TotalPrice),
TimeStart: sdkTime(v.TimeStart),
TimeEnd: sdkTime(v.TimeEnd),
}
if v.Currency != nil {
line.Currency = deref(v.Currency.CurrencyCode)
}
return line
}
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
for _, opt := range opts {
switch v := opt.(type) {
case ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(v))
case *ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(*v))
case ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(v))
case *ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(*v))
}
}
return out
}
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "CREDIT_CARD",
CardType: string(v.CreditCardType),
LastDigits: deref(v.LastDigits),
NameOnCard: deref(v.NameOnCard),
TimeExpiration: sdkTime(v.TimeExpiration),
}
}
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "PAYPAL",
Email: deref(v.EmailAddress),
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
BillingAgreement: deref(v.ExtBillingAgreementId),
}
}
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
func money(p *float32) float64 {
if p == nil {
return 0
}
return math.Round(float64(*p)*10000) / 10000
}
+47
View File
@@ -50,6 +50,13 @@ type Client interface {
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error) ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
// GetSubscription 查询单个订阅的完整信息。 // GetSubscription 查询单个订阅的完整信息。
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error) GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
// 账单(OSP Gateway,仅主区域):发票列表/明细/PDF/付款与订阅付款方式。
// ListInvoices 的 year>0 时按自然年过滤(timeInvoice 窗口),0 为全量。
ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error)
ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error)
DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error)
PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error
ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error)
// ListShapes 列出租户在目标区域可用的实例规格(带缓存)。 // ListShapes 列出租户在目标区域可用的实例规格(带缓存)。
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
// ListImages 列出可用的实例镜像。 // ListImages 列出可用的实例镜像。
@@ -91,6 +98,36 @@ type Client interface {
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。 // 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error)
ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error)
// 对象存储:namespace、桶 CRUD、对象列表/删除/重命名/取回/元数据、预签名请求(PAR)。
GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error)
ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error)
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
// AbortAllMultipartUploads 中止桶内全部未完成分片上传(清空删桶前置步骤,404 幂等)
AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error
RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error
RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error
HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error)
// 小文件中转:预览/编辑经面板直读直写,不签发 PAR;PutObject ifMatch 做并发保护。
GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error)
PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error)
CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error)
ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error)
// ListPARsPage 单页列出 PAR:page 为上页游标(空取首页),返回下一页游标(空为末页)。
ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error)
DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error
// 保留公网 IP:列出 / 创建 / 绑定(instanceID 空为解绑) / 删除。
ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error)
CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error)
AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error
AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error
DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error
AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error)
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。 // VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
@@ -152,6 +189,10 @@ type Client interface {
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
// 用户 API 签名 key 管理:列出(标注当前使用)、上传公钥(返回指纹)、按指纹删单把。
ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error)
UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error)
DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error
// 域设置:通知收件人、密码策略、身份设置。 // 域设置:通知收件人、密码策略、身份设置。
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
@@ -165,6 +206,10 @@ type Client interface {
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
// UploadDomainImage 上传公开图片到身份域存储(IdP 图标用),返回公网 URL 与存储内文件名。
UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error)
// DeleteDomainImage 按存储内文件名删除身份域公开图片。
DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
@@ -186,6 +231,8 @@ type Client interface {
type RealClient struct { type RealClient struct {
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存 limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
shapes sync.Map // tenancy|region|ad → shapeCacheEntryshape 清单缓存 shapes sync.Map // tenancy|region|ad → shapeCacheEntryshape 清单缓存
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
} }
// NewClient 返回生产使用的真实客户端。 // NewClient 返回生产使用的真实客户端。
+38 -13
View File
@@ -14,15 +14,16 @@ import (
type CostQuery struct { type CostQuery struct {
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
Granularity string // DAILY / MONTHLY Granularity string // HOURLY / DAILY / MONTHLY
QueryType string // COST / USAGE QueryType string // COST / USAGE
GroupBy string // service / skuName / region 等维度 GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子)
} }
// CostItem 是一个时间桶内某分组维度的成本或用量。 // CostItem 是一个时间桶内某分组维度的成本或用量。
type CostItem struct { type CostItem struct {
TimeStart *time.Time `json:"timeStart"` TimeStart *time.Time `json:"timeStart"`
GroupValue string `json:"groupValue"` GroupValue string `json:"groupValue"`
SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName)
ComputedAmount float32 `json:"computedAmount"` ComputedAmount float32 `json:"computedAmount"`
ComputedQuantity float32 `json:"computedQuantity"` ComputedQuantity float32 `json:"computedQuantity"`
Currency string `json:"currency"` Currency string `json:"currency"`
@@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari
TimeUsageEnded: &common.SDKTime{Time: q.EndTime}, TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
Granularity: granularity, Granularity: granularity,
QueryType: queryType, QueryType: queryType,
GroupBy: []string{q.GroupBy}, GroupBy: splitGroupBy(q.GroupBy),
}, nil }, nil
} }
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段 // splitGroupBy 把 "service,skuName" 复合维度拆成 Usage API 的 groupBy 列表
func splitGroupBy(groupBy string) []string {
parts := strings.Split(groupBy, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// dimValue 按维度名取响应条目上的分组值。
func dimValue(item usageapi.UsageSummary, dim string) string {
switch strings.ToLower(strings.TrimSpace(dim)) {
case "skuname":
return deref(item.SkuName)
case "region":
return deref(item.Region)
case "compartmentname":
return deref(item.CompartmentName)
default:
return deref(item.Service)
}
}
// toCostItem 把响应条目转为本地 DTO;首维进 GroupValue,复合分组的次维进 SubValue。
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem { func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
out := CostItem{ out := CostItem{
Currency: deref(item.Currency), Currency: deref(item.Currency),
@@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
if item.ComputedQuantity != nil { if item.ComputedQuantity != nil {
out.ComputedQuantity = *item.ComputedQuantity out.ComputedQuantity = *item.ComputedQuantity
} }
switch strings.ToLower(groupBy) { dims := splitGroupBy(groupBy)
case "skuname": if len(dims) == 0 {
out.GroupValue = deref(item.SkuName) dims = []string{"service"}
case "region": }
out.GroupValue = deref(item.Region) out.GroupValue = dimValue(item, dims[0])
case "compartmentname": if len(dims) > 1 {
out.GroupValue = deref(item.CompartmentName) out.SubValue = dimValue(item, dims[1])
default:
out.GroupValue = deref(item.Service)
} }
return out return out
} }
+157
View File
@@ -0,0 +1,157 @@
package oci
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
)
// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。
const domainImagesPath = "/storage/v1/Images"
// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl
// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient
// 发裸 multipart 请求,OCI 签名与代理配置直接复用。
func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return "", "", err
}
req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data)
if err != nil {
return "", "", err
}
resp, callErr := dc.BaseClient.Call(ctx, req)
return parseImageUploadResponse(resp, callErr)
}
// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。
func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return err
}
req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName)
if err != nil {
return err
}
resp, callErr := dc.BaseClient.Call(ctx, req)
return finishDomainImageDelete(resp, callErr)
}
func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) {
body, contentType, err := imageMultipart(fileName, data)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build image upload request: %w", err)
}
req.Header.Set("Content-Type", contentType)
return req, nil
}
func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) {
u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath)
if err != nil {
return nil, fmt.Errorf("build image delete URL: %w", err)
}
query := u.Query()
query.Set("fileName", fileName)
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("build image delete request: %w", err)
}
return req, nil
}
// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。
func imageMultipart(fileName string, data []byte) ([]byte, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", fileName)
if err != nil {
return nil, "", fmt.Errorf("build image multipart: %w", err)
}
if _, err := fw.Write(data); err != nil {
return nil, "", fmt.Errorf("build image multipart: %w", err)
}
if err := w.WriteField("fileName", fileName); err != nil {
return nil, "", fmt.Errorf("build image multipart: %w", err)
}
if err := w.Close(); err != nil {
return nil, "", fmt.Errorf("build image multipart: %w", err)
}
return buf.Bytes(), w.FormDataContentType(), nil
}
// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。
func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) {
if resp != nil && resp.Body != nil {
defer drainAndClose(resp.Body)
}
if callErr != nil {
return "", "", fmt.Errorf("upload domain image: %w", callErr)
}
if resp == nil || resp.Body == nil {
return "", "", fmt.Errorf("upload domain image: empty response")
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil {
return "", "", fmt.Errorf("read image upload response: %w", err)
}
if len(b) > 1<<20 {
return "", "", fmt.Errorf("upload domain image: response exceeds 1MB")
}
return decodeImageUploadResponse(b)
}
func decodeImageUploadResponse(data []byte) (string, string, error) {
var out struct {
FileURL string `json:"fileUrl"`
FileName string `json:"fileName"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", "", fmt.Errorf("parse image upload response: %w", err)
}
if out.FileURL == "" || out.FileName == "" {
return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName")
}
return out.FileURL, out.FileName, nil
}
func finishDomainImageDelete(resp *http.Response, callErr error) error {
if resp != nil && resp.Body != nil {
defer drainAndClose(resp.Body)
}
if resp != nil && resp.StatusCode == http.StatusNotFound {
return nil
}
if callErr != nil {
return fmt.Errorf("delete domain image: %w", callErr)
}
if resp == nil {
return fmt.Errorf("delete domain image: empty response")
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode)
}
return nil
}
// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。
func drainAndClose(body io.ReadCloser) {
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
_ = body.Close()
}
+143
View File
@@ -0,0 +1,143 @@
package oci
import (
"context"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
"testing"
)
type trackingReadCloser struct {
io.Reader
closed bool
}
type testMultipartFile struct {
name string
data []byte
}
func (r *trackingReadCloser) Close() error {
r.closed = true
return nil
}
func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) {
reader := &trackingReadCloser{Reader: strings.NewReader(body)}
return &http.Response{StatusCode: status, Body: reader}, reader
}
func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) {
t.Helper()
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content type: %v", err)
}
reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
fields, files := map[string]string{}, map[string]testMultipartFile{}
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("next part: %v", err)
}
data, _ := io.ReadAll(part)
if part.FileName() == "" {
fields[part.FormName()] = string(data)
} else {
files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data}
}
}
return fields, files
}
func TestImageMultipartIncludesFileAndName(t *testing.T) {
fileName := "idp-icon-00112233445566778899aabbccddeeff.png"
body, contentType, err := imageMultipart(fileName, []byte("image-data"))
if err != nil {
t.Fatalf("imageMultipart: %v", err)
}
fields, files := readMultipartFields(t, body, contentType)
if got := fields["fileName"]; got != fileName {
t.Errorf("fileName = %q, want %q", got, fileName)
}
file := files["file"]
if file.name != fileName || string(file.data) != "image-data" {
t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName)
}
}
func TestParseImageUploadResponse(t *testing.T) {
cases := []struct {
name, body, wantURL, wantName string
status int
callErr error
wantErr bool
}{
{"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false},
{"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true},
{"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true},
{"bad json", `{`, "", "", 200, nil, true},
{"http error hides body", `secret-body`, "", "", 400, nil, true},
{"call error", `{}`, "", "", 500, errors.New("upstream"), true},
}
for _, tc := range cases {
resp, body := testImageResponse(tc.status, tc.body)
gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr)
if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr {
t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr)
}
if !body.closed {
t.Error("response body was not closed")
}
if err != nil && strings.Contains(err.Error(), "secret-body") {
t.Errorf("error leaked response body: %v", err)
}
}
}
func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) {
fileName := "images/folder/a b+%.png"
req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName)
if err != nil {
t.Fatalf("newDomainImageDeleteRequest: %v", err)
}
if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath {
t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath)
}
if got := req.URL.Query().Get("fileName"); got != fileName {
t.Errorf("decoded fileName = %q, want %q", got, fileName)
}
if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") {
t.Errorf("raw query is not encoded: %q", req.URL.RawQuery)
}
}
func TestFinishDomainImageDelete(t *testing.T) {
cases := []struct {
name string
status int
callErr error
wantErr bool
}{
{"deleted", http.StatusNoContent, nil, false},
{"already gone", http.StatusNotFound, errors.New("not found"), false},
{"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true},
}
for _, tc := range cases {
resp, body := testImageResponse(tc.status, "response")
err := finishDomainImageDelete(resp, tc.callErr)
if (err != nil) != tc.wantErr {
t.Errorf("err = %v, wantErr %v", err, tc.wantErr)
}
if !body.closed {
t.Error("response body was not closed")
}
}
}
+102 -14
View File
@@ -3,6 +3,7 @@ package oci
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
// idpSchema 是 IdentityProvider 资源的 SCIM schema。 // idpSchema 是 IdentityProvider 资源的 SCIM schema。
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider" const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
const idpCreateRollbackWait = 15 * time.Second
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
// IdentityProviderInfo 是一个外部身份提供者的关键字段。 // IdentityProviderInfo 是一个外部身份提供者的关键字段。
type IdentityProviderInfo struct { type IdentityProviderInfo struct {
ID string `json:"id"` ID string `json:"id"`
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
TimeCreated *time.Time `json:"timeCreated,omitempty"` TimeCreated *time.Time `json:"timeCreated,omitempty"`
} }
// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。
// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。
type PartialIdentityProviderCreateError struct {
IdentityProvider IdentityProviderInfo
cause error
}
func (e *PartialIdentityProviderCreateError) Error() string {
return "identity provider was created but JIT setup is incomplete"
}
func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause }
type federationDomainsClient interface {
ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error)
CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error)
GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error)
PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error)
DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error)
}
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为: // CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、 // 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。 // 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
return idps, nil return idps, nil
} }
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT // CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT
// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) { func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentityProviderInfo{}, err return IdentityProviderInfo{}, err
} }
return createSamlIdentityProvider(ctx, dc, in)
}
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
var err error
if in.JitEnabled && in.JitAssignAdminGroup { if in.JitEnabled && in.JitAssignAdminGroup {
if adminGroup, err = adminGroupRef(ctx, dc); err != nil { if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
return IdentityProviderInfo{}, err return IdentityProviderInfo{}, err
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
if err != nil { if err != nil {
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err) return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
} }
if in.JitEnabled { info := toIdpInfo(resp.IdentityProvider)
if !in.JitEnabled {
return info, nil
}
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil { if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
return toIdpInfo(resp.IdentityProvider), err return rollbackIncompleteIdp(ctx, dc, info, err)
} }
return info, nil
} }
return toIdpInfo(resp.IdentityProvider), nil
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
if info.ID == "" {
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
}
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
defer cancel()
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
IdentityProviderId: &info.ID,
})
if rollbackErr == nil {
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
}
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
} }
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。 // buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标; // adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。 // 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) { func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1]) filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
count := len(adminGroupNames) count := len(adminGroupNames)
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{ resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
@@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
} }
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。 // ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error { func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
ref := idp.JitUserProvAttributes ref := idp.JitUserProvAttributes
if ref == nil || ref.Value == nil { if ref == nil || ref.Value == nil {
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id}) got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
@@ -302,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains
} }
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。 // rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建,
// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) { func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
returns := make([]interface{}, 0, len(items)) returns := make([]interface{}, 0, len(items)+1)
changed := false changed, seen := false, false
for _, item := range items { for _, item := range items {
name, value := deref(item.Name), deref(item.Value) name, value := deref(item.Name), deref(item.Value)
if name == "SamlIDPs" { if name == "SamlIDPs" {
seen = true
next, ok, err := toggleJSONList(value, idpID, show) next, ok, err := toggleJSONList(value, idpID, show)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err) return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
@@ -316,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho
} }
returns = append(returns, map[string]string{"name": name, "value": value}) returns = append(returns, map[string]string{"name": name, "value": value})
} }
if !seen && show {
returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)})
changed = true
}
return returns, changed, nil return returns, changed, nil
} }
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
if err != nil { if err != nil {
return nil, err return nil, err
} }
body, status, err := fetchSamlMetadata(ctx, url) body, status, err := fetchSamlMetadata(ctx, cred, url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil { if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
return nil, err return nil, err
} }
body, status, err = fetchSamlMetadata(ctx, url) body, status, err = fetchSamlMetadata(ctx, cred, url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
return body, nil return body, nil
} }
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。 // samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) { // 防异常缓慢或超大响应长期占住请求与内存。
const (
samlMetadataTimeout = 30 * time.Second
samlMetadataMaxBytes = 4 << 20
)
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名,
// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。
func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) {
client, err := metadataHTTPClient(cred)
if err != nil {
return nil, 0, err
}
ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("build metadata request: %w", err) return nil, 0, fmt.Errorf("build metadata request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("download saml metadata: %w", err) return nil, 0, fmt.Errorf("download saml metadata: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("read saml metadata: %w", err) return nil, 0, fmt.Errorf("read saml metadata: %w", err)
} }
if len(body) > samlMetadataMaxBytes {
return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes)
}
return body, resp.StatusCode, nil return body, resp.StatusCode, nil
} }
// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时
// 报错而非静默直连(失败关闭)。
func metadataHTTPClient(cred Credentials) (*http.Client, error) {
if cred.Proxy == nil {
return http.DefaultClient, nil
}
if hc := HTTPClientFor(cred.Proxy); hc != nil {
return hc, nil
}
return nil, errors.New("download saml metadata: invalid proxy config")
}
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。 // enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error { func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
dc, err := c.domainsClient(ctx, cred, region, domainID) dc, err := c.domainsClient(ctx, cred, region, domainID)
+196
View File
@@ -0,0 +1,196 @@
package oci
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identitydomains"
)
type federationCreateStub struct {
created identitydomains.IdentityProvider
createErr error
patchErr error
deleteErr error
patchCalls int
deletedID string
deleteCtxErr error
}
func (s *federationCreateStub) ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) {
return identitydomains.ListGroupsResponse{}, nil
}
func (s *federationCreateStub) CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) {
return identitydomains.CreateIdentityProviderResponse{IdentityProvider: s.created}, s.createErr
}
func (s *federationCreateStub) GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) {
return identitydomains.GetIdentityProviderResponse{IdentityProvider: s.created}, nil
}
func (s *federationCreateStub) PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) {
s.patchCalls++
return identitydomains.PatchMappedAttributeResponse{}, s.patchErr
}
func (s *federationCreateStub) DeleteIdentityProvider(ctx context.Context, request identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) {
s.deletedID = deref(request.IdentityProviderId)
s.deleteCtxErr = ctx.Err()
return identitydomains.DeleteIdentityProviderResponse{}, s.deleteErr
}
func createdJitIdp(id string) identitydomains.IdentityProvider {
return identitydomains.IdentityProvider{
Id: idPtr(id), PartnerName: common.String("test-idp"), Type: identitydomains.IdentityProviderTypeSaml,
JitUserProvEnabled: common.Bool(true), JitUserProvAttributes: &identitydomains.IdentityProviderJitUserProvAttributes{Value: common.String("mapping-1")},
}
}
func idPtr(value string) *string {
if value == "" {
return nil
}
return common.String(value)
}
func testJitInput() CreateIdpInput {
return CreateIdpInput{Name: "test-idp", JitEnabled: true, IconURL: "https://img.example/icon.png"}
}
func TestCreateSamlIdentityProviderCreateFailure(t *testing.T) {
createErr := errors.New("create rejected")
stub := &federationCreateStub{createErr: createErr}
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
if !errors.Is(err, createErr) {
t.Fatalf("err = %v, want create error", err)
}
if got.ID != "" || stub.patchCalls != 0 || stub.deletedID != "" {
t.Errorf("got = %+v, patchCalls = %d, deletedID = %q", got, stub.patchCalls, stub.deletedID)
}
}
func TestCreateSamlIdentityProviderJitFailureRollbackSuccess(t *testing.T) {
patchErr := errors.New("patch rejected")
stub := &federationCreateStub{created: createdJitIdp("idp-1"), patchErr: patchErr}
ctx, cancel := context.WithCancel(context.Background())
cancel()
got, err := createSamlIdentityProvider(ctx, stub, testJitInput())
var partial *PartialIdentityProviderCreateError
if !errors.Is(err, patchErr) || errors.As(err, &partial) {
t.Fatalf("err = %v, want ordinary wrapped patch error", err)
}
if got.ID != "" || stub.deletedID != "idp-1" || stub.deleteCtxErr != nil {
t.Errorf("got.ID = %q, deletedID = %q, deleteCtxErr = %v", got.ID, stub.deletedID, stub.deleteCtxErr)
}
}
func TestCreateSamlIdentityProviderPartialCreateContract(t *testing.T) {
cases := []struct {
name, id string
deleteErr error
wantDelete bool
}{
{"rollback fails", "idp-1", errors.New("rollback secret detail"), true},
{"created id missing", "", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { assertPartialCreate(t, tc.id, tc.deleteErr, tc.wantDelete) })
}
}
func assertPartialCreate(t *testing.T, id string, deleteErr error, wantDelete bool) {
t.Helper()
stub := &federationCreateStub{created: createdJitIdp(id), patchErr: errors.New("patch secret detail"), deleteErr: deleteErr}
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
var partial *PartialIdentityProviderCreateError
if !errors.As(err, &partial) || partial.IdentityProvider.ID != id {
t.Fatalf("got = %+v, err = %v, partial = %+v", got, err, partial)
}
if strings.Contains(err.Error(), "secret") || (stub.deletedID != "") != wantDelete {
t.Errorf("unsafe err = %q or deletedID = %q, wantDelete = %v", err, stub.deletedID, wantDelete)
}
}
func ruleReturn(name, value string) identitydomains.RuleReturn {
return identitydomains.RuleReturn{Name: common.String(name), Value: common.String(value)}
}
type rebuildIdpReturnCase struct {
name string
items []identitydomains.RuleReturn
show bool
changed bool
want string
}
func TestRebuildSamlIdpsReturn(t *testing.T) {
local := ruleReturn("LocalIDPs", `["UserNamePassword"]`)
cases := []rebuildIdpReturnCase{
{"无SamlIDPs项时添加须补建", []identitydomains.RuleReturn{local}, true, true, `["idp-1"]`},
{"无SamlIDPs项时移除无变化", []identitydomains.RuleReturn{local}, false, false, ""},
{"已有其他IdP时追加", []identitydomains.RuleReturn{local, ruleReturn("SamlIDPs", `["other"]`)}, true, true, `["other","idp-1"]`},
{"已在列表中再添加无变化", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1"]`)}, true, false, `["idp-1"]`},
{"移除目标IdP", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1","other"]`)}, false, true, `["other"]`},
{"空值项添加", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "")}, true, true, `["idp-1"]`},
}
assertRebuildIdpReturns(t, cases)
}
func assertRebuildIdpReturns(t *testing.T, cases []rebuildIdpReturnCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
returns, changed, err := rebuildSamlIdpsReturn(tc.items, "idp-1", tc.show)
if err != nil {
t.Fatalf("rebuildSamlIdpsReturn: %v", err)
}
if changed != tc.changed {
t.Errorf("changed = %v, want %v", changed, tc.changed)
}
got := ""
for _, r := range returns {
m := r.(map[string]string)
if m["name"] == "SamlIDPs" {
got = m["value"]
}
}
if got != tc.want {
t.Errorf("SamlIDPs = %q, want %q", got, tc.want)
}
})
}
}
func TestRebuildSamlIdpsReturnBadJSON(t *testing.T) {
items := []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "not-json")}
if _, _, err := rebuildSamlIdpsReturn(items, "idp-1", true); err == nil {
t.Fatal("坏 JSON 应报错而非静默覆盖")
}
}
// TestFetchSamlMetadataLimitsBody 锁定匿名元数据请求的响应体上限:超限报错而非吞下。
func TestFetchSamlMetadataLimitsBody(t *testing.T) {
huge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("x"), samlMetadataMaxBytes+1))
}))
defer huge.Close()
if _, _, err := fetchSamlMetadata(context.Background(), Credentials{}, huge.URL); err == nil ||
!strings.Contains(err.Error(), "exceeds") {
t.Fatalf("err = %v, want 超限错误", err)
}
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<EntityDescriptor/>"))
}))
defer ok.Close()
body, status, err := fetchSamlMetadata(context.Background(), Credentials{}, ok.URL)
if err != nil || status != http.StatusOK || string(body) != "<EntityDescriptor/>" {
t.Fatalf("fetch = %q, %d, %v; want 正常返回", body, status, err)
}
}
+15 -2
View File
@@ -60,9 +60,9 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
return nil, err return nil, err
} }
defer response.Body.Close() defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit)) payload, err := readCompatBody(response.Body, compatResponsesLimit, "compat responses")
if err != nil { if err != nil {
return nil, fmt.Errorf("read compat responses body: %w", err) return nil, err
} }
return payload, nil return payload, nil
} }
@@ -121,3 +121,16 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
} }
return callWithHeaderBudget(ctx, client, request, wait) return callWithHeaderBudget(ctx, client, request, wait)
} }
// readCompatBody 读取上游响应体并施加上限;读 limit+1 判超报错——
// 静默截断的 JSON/音频配 200 会被下游当完整成功记账。
func readCompatBody(body io.Reader, limit int64, tag string) ([]byte, error) {
payload, err := io.ReadAll(io.LimitReader(body, limit+1))
if err != nil {
return nil, fmt.Errorf("read %s body: %w", tag, err)
}
if int64(len(payload)) > limit {
return nil, fmt.Errorf("%s body exceeds %d bytes", tag, limit)
}
return payload, nil
}
+29
View File
@@ -1,6 +1,7 @@
package oci package oci
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
@@ -139,3 +140,31 @@ func TestCallWithHeaderBudget(t *testing.T) {
} }
}) })
} }
// TestReadCompatBodyRejectsOversize 锁定上游响应上限:恰好达限通过,超限报错,
// 不允许静默截断配 200 让下游当完整成功。
func TestReadCompatBodyRejectsOversize(t *testing.T) {
cases := []struct {
name string
size int64
wantErr bool
}{
{"under limit", 15, false},
{"exactly limit", 16, false},
{"over limit", 17, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload, err := readCompatBody(bytes.NewReader(make([]byte, tc.size)), 16, "test")
if tc.wantErr {
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("err = %v, want 超限错误", err)
}
return
}
if err != nil || int64(len(payload)) != tc.size {
t.Fatalf("payload = %d bytes, %v; want %d", len(payload), err, tc.size)
}
})
}
}
+2 -3
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/common"
@@ -36,9 +35,9 @@ func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, re
return nil, "", err return nil, "", err
} }
defer response.Body.Close() defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit)) payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech")
if err != nil { if err != nil {
return nil, "", fmt.Errorf("read compat speech body: %w", err) return nil, "", err
} }
return payload, response.Header.Get("Content-Type"), nil return payload, response.Header.Get("Content-Type"), nil
} }
+5
View File
@@ -49,6 +49,7 @@ type CreateInstanceInput struct {
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效 BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
SubnetID string // 为空时自动创建 VCN 与子网 SubnetID string // 为空时自动创建 VCN 与子网
AssignPublicIP bool AssignPublicIP bool
ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP
AssignIpv6 bool AssignIpv6 bool
SSHPublicKey string // 与 RootPassword 互斥 SSHPublicKey string // 与 RootPassword 互斥
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
@@ -189,6 +190,10 @@ func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in Cr
} }
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) { func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
// 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := core.LaunchInstanceDetails{ details := core.LaunchInstanceDetails{
CompartmentId: &compartmentID, CompartmentId: &compartmentID,
AvailabilityDomain: &in.AvailabilityDomain, AvailabilityDomain: &in.AvailabilityDomain,
+126 -60
View File
@@ -2,8 +2,6 @@ package oci
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -14,14 +12,11 @@ import (
"github.com/oracle/oci-go-sdk/v65/sch" "github.com/oracle/oci-go-sdk/v65/sch"
) )
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建; // 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生;
// 资源建在凭据默认区域,Audit 日志组含子 compartment。 // 资源建在凭据默认区域,Audit 日志组含子 compartment。
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用 // Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。 // 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
const ( const (
relayTopicPrefix = "ociportal-logs"
relayPolicyName = "ociportal-logs-sch"
relayConnectorName = "ociportal-logs"
relayAuditLogGroup = "_Audit_Include_Subcompartment" relayAuditLogGroup = "_Audit_Include_Subcompartment"
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限 relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
) )
@@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er
return sc, nil return sc, nil
} }
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。 // EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀;
// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) { func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
cp, err := c.onsControlClient(cred) cp, err := c.onsControlClient(cred)
if err != nil { if err != nil {
return RelayResource{}, err return RelayResource{}, err
} }
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok { names := relayResourceNames(cred.TenancyOCID)
return res, err res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix)
if err != nil {
return RelayResource{}, err
} }
name, err := relayTopicNewName() if ok {
refreshRelayTopicDesc(ctx, cp, res.ID)
return res, nil
}
return createRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix)
}
// createRelayTopic 以指定前缀 + 4 字节随机后缀新建 ONS Topic,描述使用中性文案。
func createRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy, prefix string) (RelayResource, error) {
name, err := relayTopicNewName(prefix)
if err != nil { if err != nil {
return RelayResource{}, err return RelayResource{}, err
} }
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{ created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
CreateTopicDetails: ons.CreateTopicDetails{ CreateTopicDetails: ons.CreateTopicDetails{
Name: &name, Name: &name,
CompartmentId: &cred.TenancyOCID, CompartmentId: &tenancy,
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"), Description: common.String(relayTopicDescNew),
}, },
}) })
if err != nil { if err != nil {
@@ -109,28 +116,17 @@ func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (Re
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
} }
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期 // findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回
func relayTopicNewName() (string, error) { // 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。
buf := make([]byte, 4) func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) {
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("topic name suffix: %w", err)
}
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
}
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
req := ons.ListTopicsRequest{CompartmentId: &tenancy} req := ons.ListTopicsRequest{CompartmentId: &tenancy}
for page := 0; page < relayTopicPages; page++ { for page := 0; page < relayTopicPages; page++ {
list, err := cp.ListTopics(ctx, req) list, err := cp.ListTopics(ctx, req)
if err != nil { if err != nil {
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err) return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
} }
for _, t := range list.Items { if res, ok := matchRelayTopic(list.Items, prefixes); ok {
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) && return res, true, nil
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
}
} }
if list.OpcNextPage == nil { if list.OpcNextPage == nil {
break break
@@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient,
return RelayResource{}, false, nil return RelayResource{}, false, nil
} }
// matchRelayTopic 在一页 Topic 中挑出第一个命名前缀命中且处于 ACTIVE 的资源。
func matchRelayTopic(items []ons.NotificationTopicSummary, prefixes []string) (RelayResource, bool) {
for _, t := range items {
if t.LifecycleState != ons.NotificationTopicSummaryLifecycleStateActive {
continue
}
name := deref(t.Name)
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true
}
}
}
return RelayResource{}, false
}
// refreshRelayTopicDesc 尽力把 Topic 描述改为中性文案;失败不阻塞主流程(权限不足等场景直接忽略)。
func refreshRelayTopicDesc(ctx context.Context, cp ons.NotificationControlPlaneClient, topicID string) {
if topicID == "" {
return
}
_, _ = cp.UpdateTopic(ctx, ons.UpdateTopicRequest{
TopicId: &topicID,
TopicAttributesDetails: ons.TopicAttributesDetails{
Description: common.String(relayTopicDescNew),
},
})
}
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建; // EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。 // 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) { func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
@@ -196,28 +221,50 @@ func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials,
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
} }
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建; // EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名;
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。 // 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) { func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
ic, err := c.identityClientAt(cred, homeRegion) ic, err := c.identityClientAt(cred, homeRegion)
if err != nil { if err != nil {
return RelayResource{}, err return RelayResource{}, err
} }
names := relayResourceNames(cred.TenancyOCID)
res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName)
if err != nil {
return RelayResource{}, err
}
if ok {
refreshRelayPolicyDesc(ctx, ic, res.ID)
return res, nil
}
return createRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName)
}
// findRelayPolicy 依次按传入的名称列表在租户范围内查找 IAM Policy;第一个命中即返回。
func findRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy string, names ...string) (RelayResource, bool, error) {
for _, name := range names {
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{ list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName), CompartmentId: &tenancy, Name: common.String(name),
}) })
if err != nil { if err != nil {
return RelayResource{}, fmt.Errorf("list policies: %w", err) return RelayResource{}, false, fmt.Errorf("list policies: %w", err)
} }
if len(list.Items) > 0 { if len(list.Items) > 0 {
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil it := list.Items[0]
return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, nil
} }
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID) }
return RelayResource{}, false, nil
}
// createRelayPolicy 以中性描述与派生名称新建 Policy,statement 允许 Service Connector 发布消息到租户内 Topic。
func createRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy, name string) (RelayResource, error) {
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", tenancy)
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{ created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
CreatePolicyDetails: identity.CreatePolicyDetails{ CreatePolicyDetails: identity.CreatePolicyDetails{
CompartmentId: &cred.TenancyOCID, CompartmentId: &tenancy,
Name: common.String(relayPolicyName), Name: common.String(name),
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"), Description: common.String(relayPolicyDescNew),
Statements: []string{stmt}, Statements: []string{stmt},
}, },
}) })
@@ -227,25 +274,45 @@ func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, ho
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
} }
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic, // refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。 func refreshRelayPolicyDesc(ctx context.Context, ic identity.IdentityClient, id string) {
if id == "" {
return
}
_, _ = ic.UpdatePolicy(ctx, identity.UpdatePolicyRequest{
PolicyId: &id,
UpdatePolicyDetails: identity.UpdatePolicyDetails{
Description: common.String(relayPolicyDescNew),
},
})
}
// EnsureRelayConnector 实现 Client:按新命名返回既有 Connector,fallback 到 legacy DisplayName;
// 命中时对齐 Log Filter 条件,未命中则以新命名新建并轮询至 ACTIVE。
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) { func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
sc, err := c.schClient(cred) sc, err := c.schClient(cred)
if err != nil { if err != nil {
return RelayResource{}, err return RelayResource{}, err
} }
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID) names := relayResourceNames(cred.TenancyOCID)
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName)
if err != nil { if err != nil {
return RelayResource{}, err return RelayResource{}, err
} }
if ok { if ok {
return res, reconcileRelayCondition(ctx, sc, res.ID, condition) return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
} }
return createRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, topicID, condition)
}
// createRelayConnector 以派生名称新建 Service Connector(_Audit 含子区间 → Topic,按 condition 过滤),
// 新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。Connector 不设 Description,避免恒定文案指纹。
func createRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name, topicID, condition string) (RelayResource, error) {
details := sch.CreateServiceConnectorDetails{ details := sch.CreateServiceConnectorDetails{
DisplayName: common.String(relayConnectorName), DisplayName: common.String(name),
CompartmentId: &cred.TenancyOCID, CompartmentId: &tenancy,
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{ Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
CompartmentId: &cred.TenancyOCID, CompartmentId: &tenancy,
LogGroupId: common.String(relayAuditLogGroup), LogGroupId: common.String(relayAuditLogGroup),
}}}, }}},
Target: sch.NotificationsTargetDetails{TopicId: &topicID}, Target: sch.NotificationsTargetDetails{TopicId: &topicID},
@@ -258,7 +325,7 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
}); err != nil { }); err != nil {
return RelayResource{}, fmt.Errorf("create service connector: %w", err) return RelayResource{}, fmt.Errorf("create service connector: %w", err)
} }
return waitRelayConnector(ctx, sc, cred.TenancyOCID) return waitRelayConnector(ctx, sc, tenancy, name)
} }
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更 // reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
@@ -300,10 +367,11 @@ func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
return *rule.Condition != want return *rule.Condition != want
} }
// findRelayConnector 按名查找存活 Connector。 // findRelayConnector 依次按传入的 DisplayName 列表在租户范围内查找存活 Connector;第一个命中即返回
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) { func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string, names ...string) (RelayResource, bool, error) {
for _, name := range names {
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{ list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName), CompartmentId: &tenancy, DisplayName: common.String(name),
}) })
if err != nil { if err != nil {
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err) return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
@@ -313,11 +381,13 @@ func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
} }
} }
}
return RelayResource{}, false, nil return RelayResource{}, false, nil
} }
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。 // waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) { // 只按新命名查找(新建资源用的就是新命名)。
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name string) (RelayResource, error) {
last := RelayResource{Created: true} last := RelayResource{Created: true}
for i := 0; i < relayConnectorPollLimit; i++ { for i := 0; i < relayConnectorPollLimit; i++ {
select { select {
@@ -325,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
return last, ctx.Err() return last, ctx.Err()
case <-time.After(relayConnectorPollTick): case <-time.After(relayConnectorPollTick):
} }
res, ok, err := findRelayConnector(ctx, sc, tenancy) res, ok, err := findRelayConnector(ctx, sc, tenancy, name)
if err != nil { if err != nil {
return last, err return last, err
} }
@@ -346,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
if err != nil { if err != nil {
return st, err return st, err
} }
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil { names := relayResourceNames(cred.TenancyOCID)
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix); err != nil {
return st, err return st, err
} }
if st.Topic.ID != "" { if st.Topic.ID != "" {
@@ -361,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
return c.relayControlState(ctx, cred, st) return c.relayControlState(ctx, cred, st)
} }
// relayControlState 补齐 Connector 与 Policy 两项状态。 // relayControlState 补齐 Connector 与 Policy 两项状态,双路径兼容 legacy 命名
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) { func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
sc, err := c.schClient(cred) sc, err := c.schClient(cred)
if err != nil { if err != nil {
return st, err return st, err
} }
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil { names := relayResourceNames(cred.TenancyOCID)
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName); err != nil {
return st, err return st, err
} }
ic, err := c.identityClientAt(cred, "") ic, err := c.identityClientAt(cred, "")
if err != nil { if err != nil {
return st, err return st, err
} }
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{ if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil {
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName), return st, err
})
if err != nil {
return st, fmt.Errorf("list policies: %w", err)
}
if len(list.Items) > 0 {
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
} }
return st, nil return st, nil
} }
+53
View File
@@ -0,0 +1,53 @@
package oci
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
)
// 日志回传链路资源命名与描述文案的集中定义。
// 目标:让每租户派生互异的中性资源名,避免跨租户恒定字面量成为「共用同一套 oci-portal」的指纹。
// relayNames 是按 tenancy 派生的一组资源名。
type relayNames struct {
TopicPrefix string // 例: "c3f2a1b0-audit",Topic 最终名再加短随机后缀
PolicyName string // 例: "c3f2a1b0-audit-p"
ConnectorName string // 例: "c3f2a1b0-audit"
}
// relayResourceNames 由 tenancyOCID 派生一组稳定的中性资源名。
// SHA-256 前 4 字节做前缀,同 tenancy 每次调用结果一致(幂等查找依赖此性质)。
func relayResourceNames(tenancyOCID string) relayNames {
sum := sha256.Sum256([]byte(tenancyOCID))
prefix := hex.EncodeToString(sum[:4])
return relayNames{
TopicPrefix: prefix + "-audit",
PolicyName: prefix + "-audit-p",
ConnectorName: prefix + "-audit",
}
}
// relayTopicNewName 按给定前缀 + 4 字节随机后缀生成 Topic 名,规避 ONS 删除保留期。
func relayTopicNewName(prefix string) (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("topic name suffix: %w", err)
}
return prefix + "-" + hex.EncodeToString(buf), nil
}
// 描述文案:中性英文,不含品牌明文;集中于此便于后续再调整。
const (
relayTopicDescNew = "Audit event relay endpoint"
relayPolicyDescNew = "Allow Service Connector to publish audit events to notification topic"
)
// legacyRelay* 仅用于识别旧版本(硬编码 ociportal-logs*)创建的存量资源,不用于新建。
// 新租户完全走 relayResourceNames 派生;存量租户 fallback 命中后描述会一次性刷新为中性文案。
const (
legacyRelayTopicPrefix = "ociportal-logs"
legacyRelayPolicyName = "ociportal-logs-sch"
legacyRelayConnectorName = "ociportal-logs"
)
+115
View File
@@ -0,0 +1,115 @@
package oci
import (
"regexp"
"strings"
"testing"
)
func TestRelayResourceNames_Deterministic(t *testing.T) {
tenancy := "ocid1.tenancy.oc1..aaaaexampletenant"
a := relayResourceNames(tenancy)
b := relayResourceNames(tenancy)
if a != b {
t.Fatalf("同 tenancy 派生不一致: %+v vs %+v", a, b)
}
}
func TestRelayResourceNames_DistinctTenancies(t *testing.T) {
cases := []string{
"ocid1.tenancy.oc1..aaaaaaaaaa",
"ocid1.tenancy.oc1..bbbbbbbbbb",
"ocid1.tenancy.oc1..cccccccccc",
}
seen := map[string]string{}
for _, tenancy := range cases {
n := relayResourceNames(tenancy)
if got, ok := seen[n.TopicPrefix]; ok {
t.Fatalf("前缀冲突: %s 与 %s 同为 %s", tenancy, got, n.TopicPrefix)
}
seen[n.TopicPrefix] = tenancy
assertNoBrandLeak(t, tenancy, n)
}
}
// assertNoBrandLeak 断言派生结果不含品牌明文,以及格式符合预期。
func assertNoBrandLeak(t *testing.T, tenancy string, n relayNames) {
t.Helper()
banned := []string{"oci-portal", "ociportal", "logs", "portal"}
fields := []struct {
name string
value string
}{
{"TopicPrefix", n.TopicPrefix},
{"PolicyName", n.PolicyName},
{"ConnectorName", n.ConnectorName},
}
for _, f := range fields {
lower := strings.ToLower(f.value)
for _, b := range banned {
if strings.Contains(lower, b) {
t.Errorf("tenancy %s: %s=%q 含品牌明文 %q", tenancy, f.name, f.value, b)
}
}
if !regexp.MustCompile(`^[0-9a-f]{8}-audit(-p)?$`).MatchString(f.value) {
t.Errorf("tenancy %s: %s=%q 不符合 <8hex>-audit(-p)? 格式", tenancy, f.name, f.value)
}
}
}
func TestRelayTopicNewName_Suffix(t *testing.T) {
tests := []struct {
name string
prefix string
}{
{name: "标准前缀", prefix: "c3f2a1b0-audit"},
{name: "legacy 前缀兼容", prefix: legacyRelayTopicPrefix},
}
pat := regexp.MustCompile(`^[0-9a-f]{8}$`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := relayTopicNewName(tt.prefix)
if err != nil {
t.Fatalf("生成失败: %v", err)
}
if !strings.HasPrefix(got, tt.prefix+"-") {
t.Errorf("前缀不符: got=%q, prefix=%q", got, tt.prefix)
}
suffix := strings.TrimPrefix(got, tt.prefix+"-")
if !pat.MatchString(suffix) {
t.Errorf("后缀非 8 hex: got=%q, suffix=%q", got, suffix)
}
})
}
}
func TestLegacyPrefixes_Unchanged(t *testing.T) {
cases := []struct {
name string
got string
want string
}{
{name: "Topic legacy", got: legacyRelayTopicPrefix, want: "ociportal-logs"},
{name: "Policy legacy", got: legacyRelayPolicyName, want: "ociportal-logs-sch"},
{name: "Connector legacy", got: legacyRelayConnectorName, want: "ociportal-logs"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("legacy 常量被误改: got=%q, want=%q", tt.got, tt.want)
}
})
}
}
func TestRelayDescriptions_Neutral(t *testing.T) {
banned := []string{"oci-portal", "ociportal", "日志回传", "面板"}
for _, desc := range []string{relayTopicDescNew, relayPolicyDescNew} {
lower := strings.ToLower(desc)
for _, b := range banned {
if strings.Contains(lower, strings.ToLower(b)) {
t.Errorf("描述含品牌明文 %q: %q", b, desc)
}
}
}
}
+2 -2
View File
@@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc
return toVCN(resp.Vcn), nil return toVCN(resp.Vcn), nil
} }
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 / // DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 /
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。 // 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error { func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
vn, err := c.vcnClient(cred, region) vn, err := c.vcnClient(cred, region)
if err != nil { if err != nil {
+755
View File
@@ -0,0 +1,755 @@
// 对象存储:namespace / 桶 / 对象 / 预签名请求(PAR)。
// 上传下载数据面走 PAR 直连 OCI,面板只做控制面;
// 例外:预览/编辑的小文件经面板中转(GetObject/PutObject),不签发 PAR。
package oci
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
)
// ErrObjectTooLarge 表示对象超出面板中转大小上限。
var ErrObjectTooLarge = errors.New("object too large for inline transfer")
// ObjectContent 是对象内容与写入所需元数据(面板中转小文件用)。
type ObjectContent struct {
Data []byte
ContentType string
Etag string
}
// Bucket 是存储桶摘要(含用量估算,来自 GetBucket 的 approximate 字段)。
type Bucket struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Visibility string `json:"visibility"` // NoPublicAccess / ObjectRead
StorageTier string `json:"storageTier"`
VersioningOn bool `json:"versioningOn"`
ApproximateCount int64 `json:"approximateCount"`
ApproximateSize int64 `json:"approximateSize"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ObjectSummary 是对象列表行;文件夹以 prefixes 单独返回。
type ObjectSummary struct {
Name string `json:"name"`
Size int64 `json:"size"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
}
// ListObjectsResult 是分页对象列表:delimiter="/" 模式,子层级归入 Prefixes。
type ListObjectsResult struct {
Objects []ObjectSummary `json:"objects"`
Prefixes []string `json:"prefixes"`
NextStartWith string `json:"nextStartWith"`
}
// ObjectDetail 是对象元数据(HEAD)。
type ObjectDetail struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
Etag string `json:"etag"`
ContentMd5 string `json:"contentMd5"`
StorageTier string `json:"storageTier"`
ArchivalState string `json:"archivalState"`
TimeModified *time.Time `json:"timeModified"`
Metadata map[string]string `json:"metadata"`
}
// PAR 是预签名请求摘要;FullURL 仅创建响应携带(OCI 事后不可再取回)。
type PAR struct {
ID string `json:"id"`
Name string `json:"name"`
ObjectName string `json:"objectName"`
AccessType string `json:"accessType"`
FullURL string `json:"fullUrl,omitempty"`
TimeExpires *time.Time `json:"timeExpires"`
TimeCreated *time.Time `json:"timeCreated"`
}
// CreateBucketInput 创建桶参数;CompartmentID 空为生效 compartment。
type CreateBucketInput struct {
Name string
CompartmentID string
PublicRead bool
StorageTier string // Standard / Archive
VersioningOn bool
}
// UpdateBucketInput 更新桶参数;nil 字段不改。
type UpdateBucketInput struct {
PublicRead *bool
VersioningOn *bool
}
// CreatePARInput 签发预签名请求参数。
type CreatePARInput struct {
Name string
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectRead / AnyObjectWrite / AnyObjectReadWrite
ExpiresHours int
}
// bulkRetryPolicy 批量删除(清空桶)的限流退避:16 并发下 OCI 可能回 429。
// 不用 DefaultRetryPolicy:其 8 次尝试、退避上限 30s,且带最终一致性模式
// (9 次、上限 45s)——大批量删除里 worker 一旦触发就长睡,吞吐塌方;
// 这里收紧为 5 次、上限 3s、关掉最终一致性,单条最坏 ~13s 后放行不拖全场。
var bulkRetryPolicy = common.NewRetryPolicyWithOptions(
common.ReplaceWithValuesFromRetryPolicy(common.DefaultRetryPolicyWithoutEventualConsistency()),
common.WithMaximumNumberAttempts(5),
common.WithExponentialBackoff(3*time.Second, 2.0),
)
func (c *RealClient) osClient(cred Credentials, region string) (objectstorage.ObjectStorageClient, error) {
oc, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider(cred))
if err != nil {
return oc, fmt.Errorf("new object storage client: %w", err)
}
applyProxy(&oc.BaseClient, cred)
if region != "" {
oc.SetRegion(normalizeRegion(region))
}
return oc, nil
}
// GetObjectStorageNamespace 实现 Client:租户 namespace(全租户唯一,常量语义),
// 首次回源后按 tenancy 进程内缓存;此前每个对象存储操作都远程取一次,
// 经代理链路时白付一整次冷连接往返。并发首次可能重复回源,结果相同无害。
func (c *RealClient) GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) {
if v, ok := c.namespaces.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
resp, err := oc.GetNamespace(ctx, objectstorage.GetNamespaceRequest{})
if err != nil {
return "", fmt.Errorf("get namespace: %w", err)
}
if cred.TenancyOCID != "" {
c.namespaces.Store(cred.TenancyOCID, deref(resp.Value))
}
return deref(resp.Value), nil
}
// ListBuckets 实现 Client:列出指定 compartment 下的桶并逐个补全用量(空为生效 compartment)。
func (c *RealClient) ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, err
}
resp, err := oc.ListBuckets(ctx, objectstorage.ListBucketsRequest{
NamespaceName: &ns,
CompartmentId: hostCompartment(cred, compartmentID),
})
if err != nil {
return nil, fmt.Errorf("list buckets: %w", err)
}
out := make([]Bucket, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, c.bucketDetail(ctx, oc, ns, deref(it.Name)))
}
return out, nil
}
// bucketDetail 取单桶详情;失败时退化为只有名字的摘要,不拖垮列表。
func (c *RealClient) bucketDetail(ctx context.Context, oc objectstorage.ObjectStorageClient, ns, name string) Bucket {
resp, err := oc.GetBucket(ctx, objectstorage.GetBucketRequest{
NamespaceName: &ns,
BucketName: &name,
Fields: []objectstorage.GetBucketFieldsEnum{"approximateCount", "approximateSize"},
})
if err != nil {
return Bucket{Name: name, Namespace: ns}
}
b := Bucket{
Name: name,
Namespace: ns,
Visibility: string(resp.PublicAccessType),
StorageTier: string(resp.StorageTier),
VersioningOn: resp.Versioning == objectstorage.BucketVersioningEnabled,
ApproximateCount: derefI64(resp.ApproximateCount),
ApproximateSize: derefI64(resp.ApproximateSize),
}
if resp.TimeCreated != nil {
b.TimeCreated = &resp.TimeCreated.Time
}
return b
}
func derefI64(p *int64) int64 {
if p != nil {
return *p
}
return 0
}
// CreateBucket 实现 Client。
func (c *RealClient) CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.CreateBucketDetails{
Name: &in.Name,
CompartmentId: hostCompartment(cred, in.CompartmentID),
PublicAccessType: objectstorage.CreateBucketDetailsPublicAccessTypeNopublicaccess,
StorageTier: objectstorage.CreateBucketDetailsStorageTierStandard,
}
if in.PublicRead {
details.PublicAccessType = objectstorage.CreateBucketDetailsPublicAccessTypeObjectread
}
if strings.EqualFold(in.StorageTier, "Archive") {
details.StorageTier = objectstorage.CreateBucketDetailsStorageTierArchive
}
if in.VersioningOn {
details.Versioning = objectstorage.CreateBucketDetailsVersioningEnabled
}
if _, err := oc.CreateBucket(ctx, objectstorage.CreateBucketRequest{
NamespaceName: &ns, CreateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("create bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, in.Name), nil
}
// UpdateBucket 实现 Client:改可见性 / 版本控制。
func (c *RealClient) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return Bucket{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return Bucket{}, err
}
details := objectstorage.UpdateBucketDetails{}
if in.PublicRead != nil {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeNopublicaccess
if *in.PublicRead {
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeObjectread
}
}
if in.VersioningOn != nil {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningSuspended
if *in.VersioningOn {
details.Versioning = objectstorage.UpdateBucketDetailsVersioningEnabled
}
}
if _, err := oc.UpdateBucket(ctx, objectstorage.UpdateBucketRequest{
NamespaceName: &ns, BucketName: &name, UpdateBucketDetails: details,
}); err != nil {
return Bucket{}, fmt.Errorf("update bucket: %w", err)
}
return c.bucketDetail(ctx, oc, ns, name), nil
}
// DeleteBucket 实现 Client:仅空桶可删,OCI 拒绝非空桶。
// ErrBucketNotEmpty 表示桶内仍有对象 / 历史版本 / 未完成分片上传,OCI 拒绝删除。
var ErrBucketNotEmpty = errors.New("bucket not empty")
func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteBucket(ctx, objectstorage.DeleteBucketRequest{
NamespaceName: &ns, BucketName: &name,
}); err != nil {
if isBucketNotEmptyErr(err) {
return fmt.Errorf("delete bucket %s: %w", name, ErrBucketNotEmpty)
}
return fmt.Errorf("delete bucket: %w", err)
}
return nil
}
// AbortAllMultipartUploads 实现 Client:中止桶内全部未完成分片上传并删除已传分片。
// 未完成分片会让「已清空」的桶仍以非空拒绝删除;404 视为桶已不存在,幂等成功。
func (c *RealClient) AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
for page := (*string)(nil); ; {
resp, err := oc.ListMultipartUploads(ctx, objectstorage.ListMultipartUploadsRequest{
NamespaceName: &ns, BucketName: &bucket, Page: page,
})
if err != nil {
return ignoreNotFound(fmt.Errorf("list multipart uploads: %w", err))
}
for _, up := range resp.Items {
if _, err := oc.AbortMultipartUpload(ctx, objectstorage.AbortMultipartUploadRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: up.Object, UploadId: up.UploadId,
}); err != nil {
if err := ignoreNotFound(fmt.Errorf("abort multipart upload: %w", err)); err != nil {
return err
}
}
}
if page = resp.OpcNextPage; page == nil {
return nil
}
}
}
// ignoreNotFound 把上游 404 归一为 nil(资源已不存在,操作目的已达成)。
func ignoreNotFound(err error) error {
var se common.ServiceError
if errors.As(err, &se) && se.GetHTTPStatusCode() == 404 {
return nil
}
return err
}
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
// 两者都可经后台清空(对象版本+PAR)后重删。
func isBucketNotEmptyErr(err error) bool {
var se common.ServiceError
if !errors.As(err, &se) {
return false
}
if se.GetCode() == "BucketNotEmpty" {
return true
}
return se.GetHTTPStatusCode() == 409 &&
strings.Contains(se.GetMessage(), "Preauthenticated Request")
}
// ObjectVersion 是对象版本摘要,清空桶时逐版本删除用。
type ObjectVersion struct {
Name string `json:"name"`
VersionID string `json:"versionId"`
}
// ListObjectVersions 实现 Client:分页列出全部对象版本(未开版本控制的桶返回当前版本)。
func (c *RealClient) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListObjectVersionsRequest{
NamespaceName: &ns, BucketName: &bucket, Limit: common.Int(1000),
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListObjectVersions(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list object versions: %w", err)
}
out := make([]ObjectVersion, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, ObjectVersion{Name: deref(it.Name), VersionID: deref(it.VersionId)})
}
return out, deref(resp.OpcNextPage), nil
}
// DeleteObjectVersion 实现 Client:删除指定版本(versionID 空为当前版本)。
func (c *RealClient) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}
if versionID != "" {
req.VersionId = &versionID
}
if _, err := oc.DeleteObject(ctx, req); err != nil {
return fmt.Errorf("delete object version: %w", err)
}
return nil
}
// ListObjects 实现 Client:delimiter="/" 前缀模式;startWith 为上页游标。
func (c *RealClient) ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ListObjectsResult{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ListObjectsResult{}, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
req := objectstorage.ListObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
Delimiter: common.String("/"),
Limit: &limit,
Fields: common.String("name,size,timeModified,storageTier,archivalState"),
}
if prefix != "" {
req.Prefix = &prefix
}
if startWith != "" {
req.Start = &startWith
}
resp, err := oc.ListObjects(ctx, req)
if err != nil {
return ListObjectsResult{}, fmt.Errorf("list objects: %w", err)
}
return toListObjectsResult(resp), nil
}
func toListObjectsResult(resp objectstorage.ListObjectsResponse) ListObjectsResult {
out := ListObjectsResult{
Objects: make([]ObjectSummary, 0, len(resp.Objects)),
Prefixes: orEmpty(resp.Prefixes),
NextStartWith: deref(resp.NextStartWith),
}
for _, o := range resp.Objects {
item := ObjectSummary{
Name: deref(o.Name),
Size: derefI64(o.Size),
StorageTier: string(o.StorageTier),
ArchivalState: string(o.ArchivalState),
}
if o.TimeModified != nil {
item.TimeModified = &o.TimeModified.Time
}
out.Objects = append(out.Objects, item)
}
return out
}
// DeleteObject 实现 Client。
func (c *RealClient) DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeleteObject(ctx, objectstorage.DeleteObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
}); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
// RenameObject 实现 Client:OCI 原生重命名(同桶内)。
func (c *RealClient) RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.RenameObject(ctx, objectstorage.RenameObjectRequest{
NamespaceName: &ns,
BucketName: &bucket,
RenameObjectDetails: objectstorage.RenameObjectDetails{
SourceName: &src,
NewName: &dst,
},
}); err != nil {
return fmt.Errorf("rename object: %w", err)
}
return nil
}
// RestoreObject 实现 Client:Archive 对象取回,hours 为可下载时长(默认 24)。
func (c *RealClient) RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
req := objectstorage.RestoreObjectsRequest{
NamespaceName: &ns,
BucketName: &bucket,
RestoreObjectsDetails: objectstorage.RestoreObjectsDetails{
ObjectName: &object,
},
}
if hours > 0 {
req.RestoreObjectsDetails.Hours = &hours
}
if _, err := oc.RestoreObjects(ctx, req); err != nil {
return fmt.Errorf("restore object: %w", err)
}
return nil
}
// HeadObject 实现 Client:对象元数据。
func (c *RealClient) HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectDetail{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectDetail{}, err
}
resp, err := oc.HeadObject(ctx, objectstorage.HeadObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectDetail{}, fmt.Errorf("head object: %w", err)
}
d := ObjectDetail{
Name: object,
Size: derefI64(resp.ContentLength),
ContentType: deref(resp.ContentType),
Etag: deref(resp.ETag),
ContentMd5: deref(resp.ContentMd5),
StorageTier: string(resp.StorageTier),
ArchivalState: string(resp.ArchivalState),
Metadata: resp.OpcMeta,
}
if resp.LastModified != nil {
d.TimeModified = &resp.LastModified.Time
}
return d, nil
}
// GetObject 实现 Client:整体读入对象内容;超过 maxBytes 返回 ErrObjectTooLarge。
func (c *RealClient) GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return ObjectContent{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return ObjectContent{}, err
}
resp, err := oc.GetObject(ctx, objectstorage.GetObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
})
if err != nil {
return ObjectContent{}, fmt.Errorf("get object: %w", err)
}
defer resp.Content.Close()
if derefI64(resp.ContentLength) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s (%d bytes): %w", object, derefI64(resp.ContentLength), ErrObjectTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return ObjectContent{}, fmt.Errorf("read object body: %w", err)
}
if int64(len(data)) > maxBytes {
return ObjectContent{}, fmt.Errorf("get object %s: %w", object, ErrObjectTooLarge)
}
return ObjectContent{Data: data, ContentType: deref(resp.ContentType), Etag: deref(resp.ETag)}, nil
}
// PutObject 实现 Client:整体写入对象;ifMatch 非空时作为 If-Match 条件,
// 不匹配由 OCI 返回 412(经 ServiceError 透出)。返回新 ETag。
func (c *RealClient) PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return "", err
}
length := int64(len(data))
req := objectstorage.PutObjectRequest{
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
ContentLength: &length,
PutObjectBody: io.NopCloser(bytes.NewReader(data)),
}
if contentType != "" {
req.ContentType = &contentType
}
if ifMatch != "" {
req.IfMatch = &ifMatch
}
resp, err := oc.PutObject(ctx, req)
if err != nil {
return "", fmt.Errorf("put object: %w", err)
}
return deref(resp.ETag), nil
}
// CreatePAR 实现 Client:签发预签名请求并拼出完整 URL(仅此时可得)。
func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return PAR{}, err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return PAR{}, err
}
details := createPARDetails(in, time.Now())
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
})
if err != nil {
return PAR{}, fmt.Errorf("create par: %w", err)
}
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
}
// createPARDetails 集中组装 SDK 请求,避免 accessType 与桶列举权限组合回归。
func createPARDetails(in CreatePARInput, now time.Time) objectstorage.CreatePreauthenticatedRequestDetails {
hours := in.ExpiresHours
if hours <= 0 {
hours = 24
}
details := objectstorage.CreatePreauthenticatedRequestDetails{
Name: &in.Name,
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
TimeExpires: &common.SDKTime{Time: now.Add(time.Duration(hours) * time.Hour)},
}
if in.ObjectName != "" {
details.ObjectName = &in.ObjectName
}
if in.AccessType == "AnyObjectRead" || in.AccessType == "AnyObjectReadWrite" {
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
}
return details
}
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.AccessUri != nil {
out.FullURL = strings.TrimSuffix(endpoint, "/") + *p.AccessUri
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// ListPARsPage 实现 Client:单页列出桶内预签名请求(摘要不含 URL),
// 返回下一页游标,空串表示已到末页;limit ≤0 时用 OCI 默认页大小。
func (c *RealClient) ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) {
oc, err := c.osClient(cred, region)
if err != nil {
return nil, "", err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return nil, "", err
}
req := objectstorage.ListPreauthenticatedRequestsRequest{NamespaceName: &ns, BucketName: &bucket}
if limit > 0 {
req.Limit = common.Int(limit)
}
if page != "" {
req.Page = &page
}
resp, err := oc.ListPreauthenticatedRequests(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list pars: %w", err)
}
out := make([]PAR, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, summaryPAR(it))
}
return out, deref(resp.OpcNextPage), nil
}
// ListPARs 实现 Client:翻页列出桶内全部有效预签名请求(清空/全部删除用)。
func (c *RealClient) ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) {
out := make([]PAR, 0, 16)
page := ""
for {
items, next, err := c.ListPARsPage(ctx, cred, region, bucket, page, 1000)
if err != nil {
return nil, err
}
out = append(out, items...)
if next == "" {
return out, nil
}
page = next
}
}
func summaryPAR(p objectstorage.PreauthenticatedRequestSummary) PAR {
out := PAR{
ID: deref(p.Id),
Name: deref(p.Name),
ObjectName: deref(p.ObjectName),
AccessType: string(p.AccessType),
}
if p.TimeExpires != nil {
out.TimeExpires = &p.TimeExpires.Time
}
if p.TimeCreated != nil {
out.TimeCreated = &p.TimeCreated.Time
}
return out
}
// DeletePAR 实现 Client:撤销预签名请求,链接立即失效。
func (c *RealClient) DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error {
oc, err := c.osClient(cred, region)
if err != nil {
return err
}
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
if err != nil {
return err
}
if _, err := oc.DeletePreauthenticatedRequest(ctx, objectstorage.DeletePreauthenticatedRequestRequest{
NamespaceName: &ns, BucketName: &bucket, ParId: &parID,
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
}); err != nil {
return fmt.Errorf("delete par: %w", err)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package oci
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
// (凭据为空也能取到,证明未走回源路径)。
func TestGetObjectStorageNamespaceCached(t *testing.T) {
c := NewClient()
tests := []struct {
name string
tenancy string
seed string
}{
{name: "缓存命中直接返回", tenancy: "ocid1.tenancy.oc1..aaaa", seed: "ns-a"},
{name: "不同租户各取各的", tenancy: "ocid1.tenancy.oc1..bbbb", seed: "ns-b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c.namespaces.Store(tt.tenancy, tt.seed)
got, err := c.GetObjectStorageNamespace(context.Background(), Credentials{TenancyOCID: tt.tenancy}, "us-ashburn-1")
if err != nil || got != tt.seed {
t.Fatalf("GetObjectStorageNamespace = %q, %v; want %q", got, err, tt.seed)
}
})
}
}
func TestCreatePARDetailsBucketListingAction(t *testing.T) {
cases := []struct {
accessType string
wantList bool
}{
{"AnyObjectRead", true},
{"AnyObjectReadWrite", true},
{"AnyObjectWrite", false},
{"ObjectRead", false},
}
for _, tc := range cases {
details := createPARDetails(CreatePARInput{AccessType: tc.accessType, ExpiresHours: 1}, time.Unix(100, 0))
gotList := string(details.BucketListingAction) == "ListObjects"
if gotList != tc.wantList {
t.Errorf("%s listing = %q, wantList %v", tc.accessType, details.BucketListingAction, tc.wantList)
}
wire, err := json.Marshal(details)
if err != nil {
t.Fatalf("marshal %s details: %v", tc.accessType, err)
}
gotWireList := strings.Contains(string(wire), `"bucketListingAction":"ListObjects"`)
if gotWireList != tc.wantList {
t.Errorf("%s wire = %s, wantList %v", tc.accessType, wire, tc.wantList)
}
}
}
func TestCreatePARDetailsExpirationAndObject(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
details := createPARDetails(CreatePARInput{
Name: "share", ObjectName: "folder/a.txt", AccessType: "ObjectRead", ExpiresHours: 48,
}, now)
if got, want := details.TimeExpires.Time, now.Add(48*time.Hour); !got.Equal(want) {
t.Errorf("TimeExpires = %v, want %v", got, want)
}
if details.ObjectName == nil || *details.ObjectName != "folder/a.txt" {
t.Errorf("ObjectName = %v, want folder/a.txt", details.ObjectName)
}
defaults := createPARDetails(CreatePARInput{AccessType: "ObjectRead"}, now)
if got, want := defaults.TimeExpires.Time, now.Add(24*time.Hour); !got.Equal(want) {
t.Errorf("default TimeExpires = %v, want %v", got, want)
}
}
+39 -11
View File
@@ -5,6 +5,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
@@ -31,6 +32,20 @@ const (
proxyTLSHandshakeTimeout = 10 * time.Second proxyTLSHandshakeTimeout = 10 * time.Second
) )
// 连接池参数:PerHost 须 > 批量删除并发数(service 层 16)——并发拨号竞速时
// Transport 会投机新建连接,池上限过紧会让这些连接用一次即被丢弃(churn);
// IdleConnTimeout 显式关闭空闲连接(零值=永不关,只能等远端 ~65s 断开,连接会堆积)。
const (
proxyMaxIdleConns = 128
proxyMaxIdleConnsPerHost = 32
proxyIdleConnTimeout = 90 * time.Second
)
// proxyClients 按代理配置复用 http.Client。SDK client 每次操作新建,
// 若 Transport 也随之新建则连接零复用:每个请求都要付完整的
// TCP+SOCKS/CONNECT+TLS 握手(经代理 3+ 个 RTT),批量操作被拖到分钟级。
var proxyClients sync.Map // ProxySpec(值) → *http.Client
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。 // applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。 // 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
func applyProxy(base *common.BaseClient, cred Credentials) { func applyProxy(base *common.BaseClient, cred Credentials) {
@@ -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 { func proxyHTTPClient(p *ProxySpec) *http.Client {
if p == nil || p.Host == "" || p.Port <= 0 { if p == nil || p.Host == "" || p.Port <= 0 {
return nil return nil
} }
if v, ok := proxyClients.Load(*p); ok {
return v.(*http.Client)
}
tr := transportFor(p) tr := transportFor(p)
if tr == nil { if tr == nil {
return nil return nil
} }
return &http.Client{Transport: tr, Timeout: proxyClientTimeout} v, _ := proxyClients.LoadOrStore(*p, &http.Client{Transport: tr, Timeout: proxyClientTimeout})
return v.(*http.Client)
} }
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK // HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
@@ -57,6 +77,16 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
return proxyHTTPClient(p) return proxyHTTPClient(p)
} }
// pooledTransport 连接池参数统一的 Transport 骨架;阶段超时见常量注释。
func pooledTransport() *http.Transport {
return &http.Transport{
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
MaxIdleConns: proxyMaxIdleConns,
MaxIdleConnsPerHost: proxyMaxIdleConnsPerHost,
IdleConnTimeout: proxyIdleConnTimeout,
}
}
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。 // transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
func transportFor(p *ProxySpec) *http.Transport { func transportFor(p *ProxySpec) *http.Transport {
addr := fmt.Sprintf("%s:%d", p.Host, p.Port) addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
@@ -66,11 +96,10 @@ func transportFor(p *ProxySpec) *http.Transport {
if p.Username != "" { if p.Username != "" {
u.User = url.UserPassword(p.Username, p.Password) u.User = url.UserPassword(p.Username, p.Password)
} }
return &http.Transport{ tr := pooledTransport()
Proxy: http.ProxyURL(u), tr.Proxy = http.ProxyURL(u)
DialContext: dialer.DialContext, tr.DialContext = dialer.DialContext
TLSHandshakeTimeout: proxyTLSHandshakeTimeout, return tr
}
} }
var auth *proxy.Auth var auth *proxy.Auth
if p.Username != "" { if p.Username != "" {
@@ -84,8 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport {
if !ok { if !ok {
return nil return nil
} }
return &http.Transport{ tr := pooledTransport()
DialContext: cd.DialContext, tr.DialContext = cd.DialContext
TLSHandshakeTimeout: proxyTLSHandshakeTimeout, return tr
}
} }
+57
View File
@@ -29,6 +29,63 @@ func TestTransportForStageTimeouts(t *testing.T) {
if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout { if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout {
t.Fatalf("TLSHandshakeTimeout = %v, 期望 %v", 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)
}
}) })
} }
} }
+60 -6
View File
@@ -3,7 +3,9 @@ package oci
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core" "github.com/oracle/oci-go-sdk/v65/core"
) )
@@ -62,7 +64,7 @@ func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIP
} }
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。 // ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作 // 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新的临时 IP
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) { func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
vn, err := c.vcnClient(cred, region) vn, err := c.vcnClient(cred, region)
if err != nil { if err != nil {
@@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
if err != nil { if err != nil {
return "", err return "", err
} }
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil { return replaceEphemeralPublicIP(ctx, vn, cred, priv)
if existing.Lifetime == core.PublicIpLifetimeReserved {
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
} }
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return "", fmt.Errorf("delete old public ip: %w", err) // 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 // OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{ resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
@@ -94,6 +109,45 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
return deref(resp.IpAddress), nil return deref(resp.IpAddress), nil
} }
// detachExistingPublicIP 释放私有 IP 上已绑定的公网 IP:
// 临时 IP 直接删除;保留 IP 仅解绑(回到未分配状态,资源不删除)。
func detachExistingPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) error {
existing := lookupPublicIP(ctx, vn, privateIPID)
if existing == nil {
return nil
}
if existing.Lifetime != core.PublicIpLifetimeReserved {
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
return fmt.Errorf("delete old public ip: %w", err)
}
return nil
}
_, err := vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: existing.Id,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: common.String("")},
})
if err != nil {
return fmt.Errorf("unassign reserved public ip: %w", err)
}
waitPublicIPDetached(ctx, vn, privateIPID)
return nil
}
// waitPublicIPDetached 短轮询等私有 IP 上的旧绑定释放;保留 IP 解绑为异步操作,
// 立即创建新临时 IP 可能撞上未释放的旧绑定。超时不报错,交由后续创建操作反馈。
func waitPublicIPDetached(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) {
for i := 0; i < 10; i++ {
if lookupPublicIP(ctx, vn, privateIPID) == nil {
return
}
select {
case <-ctx.Done():
return
case <-time.After(500 * time.Millisecond):
}
}
}
// primaryVnic 返回实例的主 VNIC。 // primaryVnic 返回实例的主 VNIC。
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) { func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID) vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
+197
View File
@@ -0,0 +1,197 @@
package oci
import (
"context"
"fmt"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
// ReservedIP 是保留公网 IP 摘要;绑定目标尽力反查,失败时对应字段为空。
type ReservedIP struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
IPAddress string `json:"ipAddress"`
LifecycleState string `json:"lifecycleState"`
AssignedInstanceID string `json:"assignedInstanceId"`
AssignedInstanceName string `json:"assignedInstanceName"`
TimeCreated *time.Time `json:"timeCreated"`
}
// ListReservedIPs 实现 Client:列出区间内全部保留公网 IP(REGION 作用域);
// compartmentID 为空时用生效 compartment。
func (c *RealClient) ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return nil, err
}
cc, err := c.computeClient(cred, region)
if err != nil {
return nil, err
}
out := make([]ReservedIP, 0, 4)
var page *string
for {
resp, err := vn.ListPublicIps(ctx, core.ListPublicIpsRequest{
Scope: core.ListPublicIpsScopeRegion,
Lifetime: core.ListPublicIpsLifetimeReserved,
CompartmentId: hostCompartment(cred, compartmentID),
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list reserved ips: %w", err)
}
for i := range resp.Items {
out = append(out, c.toReservedIP(ctx, vn, cc, resp.Items[i]))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return out, nil
}
// toReservedIP 转换 SDK 对象并尽力反查绑定的实例。
func (c *RealClient) toReservedIP(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, p core.PublicIp) ReservedIP {
var created *time.Time
if p.TimeCreated != nil {
created = &p.TimeCreated.Time
}
r := ReservedIP{
ID: deref(p.Id),
DisplayName: deref(p.DisplayName),
IPAddress: deref(p.IpAddress),
LifecycleState: string(p.LifecycleState),
TimeCreated: created,
}
if p.AssignedEntityType == core.PublicIpAssignedEntityTypePrivateIp && p.AssignedEntityId != nil {
r.AssignedInstanceID, r.AssignedInstanceName = resolveIPAssignee(ctx, vn, cc, *p.AssignedEntityId)
}
return r
}
// resolveIPAssignee 由私有 IP 反查所属实例;任一步失败即放弃,不影响列表主体。
func resolveIPAssignee(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, privateIPID string) (string, string) {
priv, err := vn.GetPrivateIp(ctx, core.GetPrivateIpRequest{PrivateIpId: &privateIPID})
if err != nil || priv.VnicId == nil {
return "", ""
}
atts, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
CompartmentId: priv.CompartmentId,
VnicId: priv.VnicId,
})
if err != nil || len(atts.Items) == 0 || atts.Items[0].InstanceId == nil {
return "", ""
}
instanceID := *atts.Items[0].InstanceId
inst, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
if err != nil {
return instanceID, ""
}
return instanceID, deref(inst.DisplayName)
}
// CreateReservedIP 实现 Client:在指定 compartment 创建保留公网 IP(空为生效 compartment)。
func (c *RealClient) CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error) {
vn, err := c.vcnClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
details := core.CreatePublicIpDetails{
CompartmentId: hostCompartment(cred, compartmentID),
Lifetime: core.CreatePublicIpDetailsLifetimeReserved,
}
if displayName != "" {
details.DisplayName = &displayName
}
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{CreatePublicIpDetails: details})
if err != nil {
return ReservedIP{}, fmt.Errorf("create reserved ip: %w", err)
}
cc, err := c.computeClient(cred, region)
if err != nil {
return ReservedIP{}, err
}
return c.toReservedIP(ctx, vn, cc, resp.PublicIp), nil
}
// AssignReservedIP 实现 Client:绑定保留 IP 到实例主私有 IP;instanceID 为空表示解绑。
// 目标私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑),地址会变更,调用方需提示用户。
func (c *RealClient) AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
target := common.String("")
if instanceID != "" {
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
target = priv.Id
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: target},
})
if err != nil {
return fmt.Errorf("assign reserved ip: %w", err)
}
return nil
}
// AssignReservedIPToVnic 实现 Client:绑定保留 IP 到指定 VNIC 的主私有 IP(次要网卡场景);
// 该私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑)。
func (c *RealClient) AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
if err != nil {
return err
}
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
return err
}
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
PublicIpId: &publicIPID,
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: priv.Id},
})
if err != nil {
return fmt.Errorf("assign reserved ip to vnic: %w", err)
}
return nil
}
// vnicPrimaryPrivateIP 取 VNIC 的主私有 IP。
func vnicPrimaryPrivateIP(ctx context.Context, vn core.VirtualNetworkClient, vnicID string) (core.PrivateIp, error) {
resp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: &vnicID})
if err != nil {
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
}
for _, p := range resp.Items {
if p.IsPrimary != nil && *p.IsPrimary {
return p, nil
}
}
return core.PrivateIp{}, fmt.Errorf("assign reserved ip: vnic has no primary private ip")
}
// DeleteReservedIP 实现 Client:删除保留公网 IP(已绑定的会先被 OCI 拒绝)。
func (c *RealClient) DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error {
vn, err := c.vcnClient(cred, region)
if err != nil {
return err
}
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: &publicIPID}); err != nil {
return fmt.Errorf("delete reserved ip: %w", err)
}
return nil
}
+11 -5
View File
@@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy"
// scimPatchSchema 是 SCIM PatchOp 的 schema。 // scimPatchSchema 是 SCIM PatchOp 的 schema。
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp" const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
// signOnConsentSchema 是修改 Oracle 预置 OCI Console sign-on 策略的知情同意扩展 schema。
const signOnConsentSchema = "urn:ietf:params:scim:schemas:oracle:idcs:extension:ociconsolesignonpolicyconsent:Policy"
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。 // SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
type SignOnRuleInfo struct { type SignOnRuleInfo struct {
ID string `json:"id"` ID string `json:"id"`
@@ -220,15 +223,18 @@ func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRu
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error { func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
var value interface{} = rules var value interface{} = rules
var consent interface{} = true
var justification interface{} = "MFA Configured in External IDP"
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{ _, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
PolicyId: common.String(ociConsolePolicyID), PolicyId: common.String(ociConsolePolicyID),
PatchOp: identitydomains.PatchOp{ PatchOp: identitydomains.PatchOp{
Schemas: []string{scimPatchSchema}, Schemas: []string{scimPatchSchema},
Operations: []identitydomains.Operations{{ // 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent
Op: identitydomains.OperationsOpReplace, Operations: []identitydomains.Operations{
Path: common.String("rules"), {Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value},
Value: &value, {Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":consent"), Value: &consent},
}}, {Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":justification"), Value: &justification},
},
}, },
}) })
if err != nil { if err != nil {
+60
View File
@@ -760,6 +760,66 @@ func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentia
return deleted, nil return deleted, nil
} }
// TenantUserApiKey 是用户名下的一把 API 签名 key。
type TenantUserApiKey struct {
Fingerprint string `json:"fingerprint"`
TimeCreated *time.Time `json:"timeCreated"`
// IsCurrent 表示该 key 正被当前配置用于签名请求
IsCurrent bool `json:"isCurrent"`
}
// ListTenantUserApiKeys 实现 Client:列出用户的 API 签名 key。
func (c *RealClient) ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return nil, err
}
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
if err != nil {
return nil, fmt.Errorf("list api keys: %w", err)
}
keys := make([]TenantUserApiKey, 0, len(resp.Items))
for _, item := range resp.Items {
k := TenantUserApiKey{Fingerprint: deref(item.Fingerprint)}
if item.TimeCreated != nil {
t := item.TimeCreated.Time
k.TimeCreated = &t
}
k.IsCurrent = userID == cred.UserOCID && k.Fingerprint == cred.Fingerprint
keys = append(keys, k)
}
return keys, nil
}
// UploadTenantUserApiKey 实现 Client:为用户上传 RSA 公钥,返回 OCI 回填的指纹。
func (c *RealClient) UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return "", err
}
resp, err := ic.UploadApiKey(ctx, identity.UploadApiKeyRequest{
UserId: &userID,
CreateApiKeyDetails: identity.CreateApiKeyDetails{Key: &publicKeyPEM},
})
if err != nil {
return "", fmt.Errorf("upload api key: %w", err)
}
return deref(resp.Fingerprint), nil
}
// DeleteTenantUserApiKey 实现 Client:按指纹删除用户的单把 API key。
func (c *RealClient) DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error {
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return err
}
_, err = ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
if err != nil {
return fmt.Errorf("delete api key %s: %w", fingerprint, err)
}
return nil
}
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。 // scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) { func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
u, found, err := domainUserByOcid(ctx, dc, classicOCID) u, found, err := domainUserByOcid(ctx, dc, classicOCID)
+26 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、 // 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。 // 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState // 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。 // 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
@@ -32,6 +32,9 @@ func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID s
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil { if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
return err return err
} }
if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil {
return err
}
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil { if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
return fmt.Errorf("delete vcn %s: %w", vcnID, err) return fmt.Errorf("delete vcn %s: %w", vcnID, err)
} }
@@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com
} }
return nil return nil
} }
// deleteVcnNsgs 删除 VCN 下全部网络安全组:遗留 NSG 会使 DeleteVcn 报
// 409 associated with NSG。仅按 VcnId 过滤,跨 compartment 的 NSG 也能覆盖;
// 子网删除后 VNIC 已不存在,NSG 不再有关联,可直接删除。
func deleteVcnNsgs(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
resp, err := vn.ListNetworkSecurityGroups(ctx, core.ListNetworkSecurityGroupsRequest{VcnId: &vcnID})
if err != nil {
return fmt.Errorf("list network security groups: %w", err)
}
for _, g := range resp.Items {
if g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminated ||
g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminating {
continue
}
if _, err := vn.DeleteNetworkSecurityGroup(ctx, core.DeleteNetworkSecurityGroupRequest{
NetworkSecurityGroupId: g.Id,
}); err != nil {
return fmt.Errorf("delete network security group %s: %w", deref(g.DisplayName), err)
}
}
return nil
}
+6
View File
@@ -31,6 +31,8 @@ type AttachVnicInput struct {
AssignPublicIP bool `json:"assignPublicIp"` AssignPublicIP bool `json:"assignPublicIp"`
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。 // AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
AssignIpv6 bool `json:"assignIpv6"` AssignIpv6 bool `json:"assignIpv6"`
// ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。
ReservedPublicIPID string `json:"reservedPublicIpId"`
} }
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。 // ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
@@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i
if err != nil { if err != nil {
return Vnic{}, err return Vnic{}, err
} }
// 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次
if in.ReservedPublicIPID != "" {
in.AssignPublicIP = false
}
details := &core.CreateVnicDetails{ details := &core.CreateVnicDetails{
SubnetId: &in.SubnetID, SubnetId: &in.SubnetID,
AssignPublicIp: &in.AssignPublicIP, AssignPublicIp: &in.AssignPublicIP,
+48 -5
View File
@@ -1048,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
if err != nil || !ok { if err != nil || !ok {
return err return err
} }
on, err := contentLogStillOn(tx, entry.KeyID)
if err != nil || !on {
return err
}
return tx.Create(&entry).Error return tx.Create(&entry).Error
}) })
if err != nil { if err != nil {
@@ -1055,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
} }
} }
// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入,
// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。
func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) {
var key model.AiKey
err := tx.First(&key, keyID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil
}
func truncateBody(s string) string { func truncateBody(s string) string {
if len(s) > aiContentBodyLimit { if len(s) > aiContentBodyLimit {
return s[:aiContentBodyLimit] return s[:aiContentBodyLimit]
@@ -1110,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log") s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
} }
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。 // cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限
// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。
var cleanupBatch = 10000
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环,
// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) { func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
cutoff := time.Now().Add(-retention) cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil { if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
@@ -1119,15 +1142,35 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
} }
var total int64 var total int64
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil { if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
log.Printf("%s cleanup count: %v", tag, err)
return return
} }
if overflow := int(total) - maxRows; overflow > 0 { for overflow := int(total) - maxRows; overflow > 0; {
n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch))
if err != nil {
log.Printf("%s cleanup overflow: %v", tag, err)
return
}
if n == 0 {
return
}
overflow -= n
}
}
// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。
func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) {
var ids []uint var ids []uint
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids) if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
if len(ids) > 0 { return 0, fmt.Errorf("pluck oldest: %w", err)
s.db.WithContext(ctx).Delete(m, ids)
} }
if len(ids) == 0 {
return 0, nil
} }
if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil {
return 0, fmt.Errorf("delete batch: %w", err)
}
return len(ids), nil
} }
// Wait 等待后台清理 goroutine 退出。 // Wait 等待后台清理 goroutine 退出。
+70
View File
@@ -11,6 +11,8 @@ import (
"oci-portal/internal/aiwire" "oci-portal/internal/aiwire"
"oci-portal/internal/model" "oci-portal/internal/model"
"oci-portal/internal/oci" "oci-portal/internal/oci"
"gorm.io/gorm"
) )
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。 // TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err) t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
} }
} }
// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为:
// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。
func TestCleanupTableBatchesOverflow(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
old := cleanupBatch
cleanupBatch = 3
t.Cleanup(func() { cleanupBatch = old })
for i := 0; i < 10; i++ {
if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// maxRows=2:溢出 8 行,需 3 轮批次(3+3+2)
gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test")
var rest []model.AiCallLog
if err := gw.db.Order("id").Find(&rest).Error; err != nil {
t.Fatalf("load rest: %v", err)
}
if len(rest) != 2 {
t.Fatalf("remaining = %d, want 2", len(rest))
}
for _, row := range rest {
if row.ID <= 8 {
t.Errorf("row %d survived, want oldest deleted first", row.ID)
}
}
}
// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥,
// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。
func TestLogContentRechecksKeyInsideTx(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
t.Fatalf("migrate content log: %v", err)
}
future := time.Now().Add(time.Hour)
key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future}
call := model.AiCallLog{Endpoint: "chat"}
if err := gw.db.Create(&key).Error; err != nil {
t.Fatalf("seed key: %v", err)
}
if err := gw.db.Create(&call).Error; err != nil {
t.Fatalf("seed call: %v", err)
}
countIs := func(want int64, note string) {
t.Helper()
var n int64
if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want {
t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want)
}
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"})
countIs(1, "窗口开启时应写入")
// 管理员立即关闭窗口:在途请求携带的旧快照不得再写
if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID).
Update("content_log_until", gorm.Expr("NULL")).Error; err != nil {
t.Fatalf("close window: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"})
countIs(1, "窗口关闭后不得写入")
// 密钥删除后同样拒写
if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil {
t.Fatalf("delete key: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"})
countIs(1, "密钥已删后不得写入")
}
+23 -2
View File
@@ -7,6 +7,18 @@ import (
"oci-portal/internal/oci" "oci-portal/internal/oci"
) )
// ChangeVnicPublicIP 更换指定 VNIC 的临时公网 IP,返回新地址(旧保留 IP 自动解绑)。
func (s *OciConfigService) ChangeVnicPublicIP(ctx context.Context, id uint, region, vnicID string) (string, error) {
if vnicID == "" {
return "", fmt.Errorf("change vnic public ip: vnicId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.ChangeVnicPublicIP(ctx, cred, region, vnicID)
}
// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。 // ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) { func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
cred, err := s.credentialsByID(ctx, id) cred, err := s.credentialsByID(ctx, id)
@@ -46,7 +58,7 @@ func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, i
return s.client.ListInstanceVnics(ctx, cred, region, instanceID) return s.client.ListInstanceVnics(ctx, cred, region, instanceID)
} }
// AttachVnic 为实例附加次要 VNIC。 // AttachVnic 为实例附加次要 VNIC;选了保留 IP 时由后台等网卡就绪后绑定
func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) { func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
if instanceID == "" || in.SubnetID == "" { if instanceID == "" || in.SubnetID == "" {
return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required") return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required")
@@ -55,7 +67,16 @@ func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, inst
if err != nil { if err != nil {
return oci.Vnic{}, err return oci.Vnic{}, err
} }
return s.client.AttachVnic(ctx, cred, region, instanceID, in) vnic, err := s.client.AttachVnic(ctx, cred, region, instanceID, in)
if err != nil {
return vnic, err
}
if in.ReservedPublicIPID != "" {
s.goBind(func() {
s.bindReservedIPToVnicWhenReady(cred, region, instanceID, vnic.AttachmentID, in.ReservedPublicIPID)
})
}
return vnic, nil
} }
// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。 // DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。
+72
View File
@@ -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)
}
+109
View File
@@ -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)
}
})
}
}
+26 -5
View File
@@ -8,6 +8,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model" "oci-portal/internal/model"
) )
@@ -119,16 +120,22 @@ func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
} }
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。 // SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
// 检查与写入在同一事务内并锁定用户行,防与解绑身份并发绕过「至少一种登录方式」。
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error { func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
if s.settings == nil { if s.settings == nil {
return errors.New("settings unavailable") return errors.New("settings unavailable")
} }
value := ""
if disabled { if disabled {
user, err := s.findUser(ctx, username) value = "1"
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil { if err != nil {
return err return err
} }
n, err := s.identityCount(ctx, user.ID) if disabled {
n, err := identityCountTx(tx, user.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -136,16 +143,30 @@ func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username str
return ErrNeedIdentity return ErrNeedIdentity
} }
} }
if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil { return saveSettingTx(tx, settingSecPasswordLoginOff, value)
})
if err != nil {
return err return err
} }
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效 // 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
return s.bumpTokenVersion(ctx, username) return s.bumpTokenVersion(ctx, username)
} }
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) { // lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。
func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
var user model.User
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user, "username = ?", username).Error
if err != nil {
return nil, fmt.Errorf("find user %s: %w", username, err)
}
return &user, nil
}
func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
var count int64 var count int64
err := s.db.WithContext(ctx).Model(&model.UserIdentity{}). err := tx.Model(&model.UserIdentity{}).
Where("user_id = ?", userID).Count(&count).Error Where("user_id = ?", userID).Count(&count).Error
if err != nil { if err != nil {
return 0, fmt.Errorf("count identities: %w", err) return 0, fmt.Errorf("count identities: %w", err)
+172 -1
View File
@@ -2,12 +2,36 @@ package service
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt" "fmt"
"io"
"path"
"strings" "strings"
"unicode"
"oci-portal/internal/oci" "oci-portal/internal/oci"
) )
// idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。
const (
idpIconMaxBytes = 1 << 20
idpIconRandomBytes = 16
)
var idpIconExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true,
}
// 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。
var (
ErrIdpIconEmpty = errors.New("icon file is empty")
ErrIdpIconTooLarge = errors.New("icon exceeds 1MB")
ErrIdpIconBadType = errors.New("icon file type not allowed")
ErrIdpIconBadName = errors.New("icon file name is invalid")
)
// IdentityProviders 列出域内 SAML 身份提供者。 // IdentityProviders 列出域内 SAML 身份提供者。
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) { func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
@@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
if err != nil { if err != nil {
return oci.IdentityProviderInfo{}, err return oci.IdentityProviderInfo{}, err
} }
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in)) in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in)
}
func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) {
if !in.JitEnabled {
return in, nil
}
setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID)
if err != nil {
return in, fmt.Errorf("get identity setting before create idp: %w", err)
}
if setting.PrimaryEmailRequired {
in.JitMapEmail = true
}
return in, nil
} }
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、 // normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
@@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID) return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
} }
// UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。
func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) {
return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader)
}
func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) {
if len(data) == 0 {
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty)
}
if len(data) > idpIconMaxBytes {
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge)
}
fileName, err := validateIdpIcon(fileName, data)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
fileName, err = newIdpIconStorageName(fileName, random)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return "", "", err
}
url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
return url, storedName, nil
}
func newIdpIconStorageName(fileName string, random io.Reader) (string, error) {
token := make([]byte, idpIconRandomBytes)
if _, err := io.ReadFull(random, token); err != nil {
return "", fmt.Errorf("generate storage name: %w", err)
}
ext := strings.ToLower(path.Ext(fileName))
return "idp-icon-" + hex.EncodeToString(token) + ext, nil
}
// DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。
func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error {
if err := validateDomainImageFileName(fileName); err != nil {
return fmt.Errorf("delete idp icon: %w", err)
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil {
return fmt.Errorf("delete idp icon: %w", err)
}
return nil
}
func validateIdpIcon(input string, data []byte) (string, error) {
fileName := path.Base(strings.ReplaceAll(input, "\\", "/"))
if invalidIconName(fileName, 255) {
return "", ErrIdpIconBadName
}
ext := strings.ToLower(path.Ext(fileName))
if !idpIconExts[ext] || !iconMagicOK(ext, data) {
return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext)
}
return fileName, nil
}
func validateDomainImageFileName(fileName string) error {
if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") {
return ErrIdpIconBadName
}
clean := path.Clean(fileName)
if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) {
return ErrIdpIconBadName
}
return nil
}
func validIdpIconStorageBase(fileName string) bool {
ext := path.Ext(fileName)
if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] {
return false
}
stem := strings.TrimSuffix(fileName, ext)
if !strings.HasPrefix(stem, "idp-icon-") {
return false
}
token := strings.TrimPrefix(stem, "idp-icon-")
if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) {
return false
}
_, err := hex.DecodeString(token)
return err == nil
}
func invalidIconName(fileName string, maxBytes int) bool {
return fileName == "" || fileName == "." || fileName == ".." ||
len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName ||
strings.IndexFunc(fileName, unicode.IsControl) >= 0
}
// iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码:
// 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会
// 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。
func iconMagicOK(ext string, data []byte) bool {
has := func(prefix string) bool {
return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix
}
switch ext {
case ".png":
return has("\x89PNG\r\n\x1a\n")
case ".jpg", ".jpeg":
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
case ".gif":
return has("GIF87a") || has("GIF89a")
case ".webp":
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP"
case ".ico":
return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0
case ".svg":
head := data
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(strings.ToLower(string(head)), "<svg")
}
return false
}
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。 // ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) { func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
+227
View File
@@ -0,0 +1,227 @@
package service
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"oci-portal/internal/oci"
)
type idpIconStubClient struct {
*fakeClient
uploadedNames []string
}
type idpCreateStubClient struct {
*fakeClient
setting oci.IdentitySettingInfo
settingErr error
settingCalls int
createCalls int
createdInput oci.CreateIdpInput
}
func (c *idpIconStubClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
c.uploadedNames = append(c.uploadedNames, fileName)
return "https://images.example/" + fileName, "images/" + fileName, nil
}
func (c *idpCreateStubClient) GetIdentitySetting(_ context.Context, _ oci.Credentials, _, _ string) (oci.IdentitySettingInfo, error) {
c.settingCalls++
return c.setting, c.settingErr
}
func (c *idpCreateStubClient) CreateSamlIdentityProvider(_ context.Context, _ oci.Credentials, _, _ string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
c.createCalls++
c.createdInput = in
return oci.IdentityProviderInfo{Name: in.Name}, nil
}
type failingIconRandom struct{}
func (failingIconRandom) Read([]byte) (int, error) {
return 0, errors.New("entropy unavailable")
}
func newIdpIconTestService(t *testing.T) (*OciConfigService, *idpIconStubClient, uint) {
t.Helper()
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
client := &idpIconStubClient{fakeClient: base}
service := newTestService(t, client)
return service, client, importAliveConfig(t, service).ID
}
func newIdpCreateTestService(t *testing.T) (*OciConfigService, *idpCreateStubClient, uint) {
t.Helper()
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
client := &idpCreateStubClient{fakeClient: base}
service := newTestService(t, client)
return service, client, importAliveConfig(t, service).ID
}
func testCreateIdpInput(jitEnabled bool) oci.CreateIdpInput {
return oci.CreateIdpInput{
Name: "test-idp", Metadata: "<EntityDescriptor/>", JitEnabled: jitEnabled,
}
}
// iconFixture 生成带正确文件头魔数的最小样本;精简校验只核对魔数,不做深度解码。
func iconFixture(ext string) []byte {
switch ext {
case ".png":
return append([]byte("\x89PNG\r\n\x1a\n"), 0, 0, 0, 0)
case ".jpg", ".jpeg":
return []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0}
case ".gif":
return []byte("GIF89a\x00\x00")
case ".webp":
return []byte("RIFF\x00\x00\x00\x00WEBPVP8 ")
case ".ico":
return []byte{0, 0, 1, 0, 1, 0}
case ".svg":
return []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
}
return nil
}
func TestValidateIdpIconAcceptsMatchingContent(t *testing.T) {
for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg"} {
t.Run(ext, func(t *testing.T) {
fileName := "icon" + ext
got, err := validateIdpIcon(fileName, iconFixture(ext))
if err != nil || got != fileName {
t.Errorf("validateIdpIcon = %q, %v; want %q, nil", got, err, fileName)
}
})
}
}
func TestCreateIdentityProviderForcesEmailMapping(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.setting.PrimaryEmailRequired = true
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
if err != nil {
t.Fatalf("CreateIdentityProvider: %v", err)
}
if client.settingCalls != 1 || client.createCalls != 1 {
t.Fatalf("calls = setting:%d create:%d; want 1, 1", client.settingCalls, client.createCalls)
}
if !client.createdInput.JitMapEmail {
t.Error("JitMapEmail = false, want true for primary-email-required domain")
}
}
func TestCreateIdentityProviderStopsWhenSettingFails(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.settingErr = errors.New("setting unavailable")
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
if err == nil || !strings.Contains(err.Error(), "get identity setting") {
t.Fatalf("CreateIdentityProvider error = %v, want setting failure", err)
}
if client.settingCalls != 1 || client.createCalls != 0 {
t.Errorf("calls = setting:%d create:%d; want 1, 0", client.settingCalls, client.createCalls)
}
}
func TestCreateIdentityProviderSkipsSettingWithoutJIT(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.settingErr = errors.New("must not be called")
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(false))
if err != nil {
t.Fatalf("CreateIdentityProvider: %v", err)
}
if client.settingCalls != 0 || client.createCalls != 1 {
t.Errorf("calls = setting:%d create:%d; want 0, 1", client.settingCalls, client.createCalls)
}
}
func TestUploadIdpIconUsesUniqueServerNames(t *testing.T) {
service, client, configID := newIdpIconTestService(t)
data := iconFixture(".png")
randomData := append(bytes.Repeat([]byte{0x11}, idpIconRandomBytes), bytes.Repeat([]byte{0x22}, idpIconRandomBytes)...)
random := bytes.NewReader(randomData)
storedNames := make([]string, 2)
for index := range storedNames {
_, storedName, err := service.uploadIdpIcon(context.Background(), configID, "domain", "Logo.PNG", data, random)
if err != nil {
t.Fatalf("upload %d: %v", index, err)
}
storedNames[index] = storedName
}
wants := []string{"idp-icon-" + strings.Repeat("11", idpIconRandomBytes) + ".png", "idp-icon-" + strings.Repeat("22", idpIconRandomBytes) + ".png"}
for index, want := range wants {
if client.uploadedNames[index] != want || storedNames[index] != "images/"+want {
t.Errorf("upload %d = %q, %q; want %q, %q", index, client.uploadedNames[index], storedNames[index], want, "images/"+want)
}
}
}
func TestUploadIdpIconRandomFailureSkipsUpstream(t *testing.T) {
service, client, configID := newIdpIconTestService(t)
_, _, err := service.uploadIdpIcon(context.Background(), configID, "domain", "logo.png", iconFixture(".png"), failingIconRandom{})
if err == nil || !strings.Contains(err.Error(), "generate storage name") {
t.Fatalf("UploadIdpIcon error = %v, want random source failure", err)
}
if len(client.uploadedNames) != 0 {
t.Errorf("upstream upload calls = %d, want 0", len(client.uploadedNames))
}
}
type iconRejectCase struct {
name, fileName string
data []byte
want error
}
func assertIconRejected(t *testing.T, cases []iconRejectCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateIdpIcon(tc.fileName, tc.data)
if !errors.Is(err, tc.want) {
t.Errorf("err = %v, want errors.Is(%v)", err, tc.want)
}
})
}
}
func TestValidateIdpIconRejectsSpoofedContent(t *testing.T) {
assertIconRejected(t, []iconRejectCase{
{"extension mismatch", "icon.jpg", iconFixture(".png"), ErrIdpIconBadType},
{"random bytes as png", "icon.png", []byte("not an image"), ErrIdpIconBadType},
{"unsupported extension", "icon.bmp", iconFixture(".png"), ErrIdpIconBadType},
})
}
func TestValidateIdpIconRejectsBadNames(t *testing.T) {
assertIconRejected(t, []iconRejectCase{
{"control name", "bad\r\n.png", iconFixture(".png"), ErrIdpIconBadName},
{"long name", strings.Repeat("a", 252) + ".png", iconFixture(".png"), ErrIdpIconBadName},
})
}
func TestValidateDomainImageFileName(t *testing.T) {
token := strings.Repeat("ab", idpIconRandomBytes)
cases := []struct {
name, fileName string
valid bool
}{
{"generated image", "images/idp-icon-" + token + ".png", true},
{"empty", "", false}, {"wrong prefix", "icons/a.png", false},
{"traversal", "images/a/../secret", false}, {"backslash", `images\a.png`, false},
{"control", "images/a\x00.png", false}, {"directory", "images/", false},
{"unrelated domain image", "images/company-brand.png", false},
{"nested image", "images/generated/idp-icon-" + token + ".png", false},
{"uppercase token", "images/idp-icon-" + strings.ToUpper(token) + ".png", false},
{"wrong token length", "images/idp-icon-ab.png", false},
}
for _, tc := range cases {
err := validateDomainImageFileName(tc.fileName)
if (err == nil) != tc.valid {
t.Errorf("%s: err = %v, valid = %v", tc.name, err, tc.valid)
}
}
}
+7
View File
@@ -37,6 +37,9 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
if count < 1 || count > maxBatchCreate { if count < 1 || count > maxBatchCreate {
return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate) return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate)
} }
if in.ReservedPublicIPID != "" && count > 1 {
return nil, nil, fmt.Errorf("create instances: reserved public ip only supports single instance")
}
if err := validateCreateInstance(in); err != nil { if err := validateCreateInstance(in); err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -59,6 +62,10 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err))) failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err)))
continue continue
} }
if one.ReservedPublicIPID != "" {
region, instanceID, ipID := one.Region, instance.ID, one.ReservedPublicIPID
s.goBind(func() { s.bindReservedIPWhenReady(cred, region, instanceID, ipID) })
}
instances = append(instances, instance) instances = append(instances, instance)
} }
return instances, failures, nil return instances, failures, nil
+2
View File
@@ -286,6 +286,8 @@ func TestParseLogEvent(t *testing.T) {
wantType: "x", wantType: "x",
}, },
{ {
// message 中的 ociportal-logs-sch 是历史 Policy 命名 fixture,反映升级前存量租户的审计事件形态;
// 新版命名已按 tenancy 派生,见 internal/oci/logrelay_names.go。
name: "Audit v2 提取操作者成败与策略描述", name: "Audit v2 提取操作者成败与策略描述",
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{ payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"}, "identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
+30 -9
View File
@@ -329,27 +329,48 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
} }
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。 // Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error { func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
user, err := o.auth.findUser(ctx, username) err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil { if err != nil {
return err return err
} }
n, err := o.auth.identityCount(ctx, user.ID) if err := ensureNotLastLogin(tx, user.ID); err != nil {
if err != nil {
return err return err
} }
if n <= 1 { res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
return ErrLastIdentity
}
}
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
if res.Error != nil { if res.Error != nil {
return fmt.Errorf("unbind identity: %w", res.Error) return fmt.Errorf("unbind identity: %w", res.Error)
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
return nil
})
if err != nil {
return err
}
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效 // 解绑属敏感变更:递增令牌版本,已签发会话全部失效
return o.auth.bumpTokenVersion(ctx, username) return o.auth.bumpTokenVersion(ctx, username)
} }
// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑;
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
func ensureNotLastLogin(tx *gorm.DB, userID uint) error {
n, err := identityCountTx(tx, userID)
if err != nil {
return err
}
if n > 1 {
return nil
}
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
if err != nil {
return err
}
if off == "1" {
return ErrLastIdentity
}
return nil
}
+54 -21
View File
@@ -32,18 +32,19 @@ type OAuthProvidersView struct {
GithubDisabled bool `json:"githubDisabled"` GithubDisabled bool `json:"githubDisabled"`
} }
// UpdateOAuthInput 是保存 provider 配置的输入; // UpdateOAuthInput 是 provider 配置的部分更新:所有字段 nil=沿用已存值,
// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。 // 只落库出现的字段;并发编辑不同 provider 因此互不回滚(2026-07-22 审查 #18)。
// secret 非 nil 覆盖(空串清除),绝不回读。
type UpdateOAuthInput struct { type UpdateOAuthInput struct {
OidcIssuer string `json:"oidcIssuer"` OidcIssuer *string `json:"oidcIssuer"`
OidcClientID string `json:"oidcClientId"` OidcClientID *string `json:"oidcClientId"`
OidcClientSecret *string `json:"oidcClientSecret"` OidcClientSecret *string `json:"oidcClientSecret"`
OidcDisplayName string `json:"oidcDisplayName"` OidcDisplayName *string `json:"oidcDisplayName"`
OidcDisabled bool `json:"oidcDisabled"` OidcDisabled *bool `json:"oidcDisabled"`
GithubClientID string `json:"githubClientId"` GithubClientID *string `json:"githubClientId"`
GithubClientSecret *string `json:"githubClientSecret"` GithubClientSecret *string `json:"githubClientSecret"`
GithubDisplayName string `json:"githubDisplayName"` GithubDisplayName *string `json:"githubDisplayName"`
GithubDisabled bool `json:"githubDisabled"` GithubDisabled *bool `json:"githubDisabled"`
} }
// OAuthView 返回脱敏后的 provider 配置。 // OAuthView 返回脱敏后的 provider 配置。
@@ -77,19 +78,51 @@ func boolFlag(on bool) string {
return "" return ""
} }
// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库 // trimPtr / issuerPtr / flagPtr 把补丁字段规范化为存储值;nil 表示未出现不写
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error { func trimPtr(p *string) *string {
plain := map[string]string{ if p == nil {
settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"), return nil
settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID),
settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName),
settingOauthOidcDisabled: boolFlag(in.OidcDisabled),
settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID),
settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName),
settingOauthGithubDisabled: boolFlag(in.GithubDisabled),
} }
for key, value := range plain { v := strings.TrimSpace(*p)
if err := s.set(ctx, key, value); err != nil { return &v
}
func issuerPtr(p *string) *string {
if p == nil {
return nil
}
v := strings.TrimRight(strings.TrimSpace(*p), "/")
return &v
}
func flagPtr(p *bool) *string {
if p == nil {
return nil
}
v := boolFlag(*p)
return &v
}
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;
// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
writes := []struct {
key string
val *string
}{
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
{settingOauthOidcClientID, trimPtr(in.OidcClientID)},
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
{settingOauthGithubClientID, trimPtr(in.GithubClientID)},
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
}
for _, w := range writes {
if w.val == nil {
continue
}
if err := s.set(ctx, w.key, *w.val); err != nil {
return err return err
} }
} }
+410
View File
@@ -0,0 +1,410 @@
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
}
key := fmt.Sprintf("%d/%s/%s", id, region, name)
if !s.beginPurge(key) {
return true, nil // 已有清空任务在途,视为已排队
}
s.bindWG.Add(1)
go func() {
defer s.bindWG.Done()
defer s.endPurge(key)
s.purgeAndDeleteBucket(cred, region, name)
}()
return true, nil
}
// beginPurge / endPurge 维护桶清空在途注册表(键 cfgID/region/bucket)。
func (s *OciConfigService) beginPurge(key string) bool {
s.purgeMu.Lock()
defer s.purgeMu.Unlock()
if s.purgeInFlight[key] {
return false
}
s.purgeInFlight[key] = true
return true
}
func (s *OciConfigService) endPurge(key string) {
s.purgeMu.Lock()
defer s.purgeMu.Unlock()
delete(s.purgeInFlight, key)
}
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
const purgeBucketTimeout = 30 * time.Minute
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
// 顺序:对象版本 → PAR → 未完成分片 → 删桶;分片不清会让空桶仍以非空拒绝删除。
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
ctx, cancel := s.purgeContext()
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.AbortAllMultipartUploads(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s abort multipart uploads 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))
}
// purgeContext 组装清空任务的 ctx:总超时之外叠加关服信号,
// 避免 Stop 等待在途清空长达整个超时窗口。
func (s *OciConfigService) purgeContext() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
done := make(chan struct{})
go func() {
select {
case <-s.bindStop:
cancel()
case <-done:
}
}()
return ctx, func() { close(done); cancel() }
}
// 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,
"AnyObjectRead": true, "AnyObjectWrite": true, "AnyObjectReadWrite": true,
}
// PAR 输入校验错误供 API 层稳定映射 400。
var (
ErrPARInvalidAccessType = errors.New("invalid PAR access type")
ErrPARInvalidExpiration = errors.New("invalid PAR expiration")
)
// maxParHours 是 PAR 有效期安全上限(100 年):time.Duration 按小时乘会在
// ≈292 年(2562047h)溢出回绕为负,收在远低于溢出点又远超实际需求的位置。
const maxParHours = 100 * 365 * 24
// CreatePAR 签发预签名请求;过期时长 1 小时到 100 年(放开旧 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: %w: unsupported access type %q", ErrPARInvalidAccessType, in.AccessType)
}
if in.ExpiresHours < 1 || in.ExpiresHours > maxParHours {
return oci.PAR{}, fmt.Errorf("create par: %w: expiresHours must be within 1-%d", ErrPARInvalidExpiration, maxParHours)
}
if strings.TrimSpace(in.Name) == "" {
base := in.ObjectName
if base == "" {
base = "bucket-" + bucket
}
in.Name = "par-" + strings.ReplaceAll(base, "/", "-")
}
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
}
+541
View File
@@ -0,0 +1,541 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/model"
"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
multipartAborted bool
listCalls int
}
func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.multipartAborted = true
return nil
}
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()
f.listCalls++
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: 0})
return err
}, wantErr: "1-876000"},
// 旧 30 天上限已放开:10 年应放行
{name: "PAR 超长有效期放行", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650})
return err
}},
// 防 time.Duration 溢出的安全上限(100 年)仍要拦截
{name: "PAR 超安全上限拒绝", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1})
return err
}, wantErr: "1-876000"},
}
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")
}
}
// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。
func TestCreatePARAccessTypes(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
cases := []struct {
accessType string
wantErr bool
}{
{"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false},
{"BucketRead", true}, {"", true},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24})
if (err != nil) != tc.wantErr {
t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr)
}
}
if client.createdPAR.Name != "par-bucket-b" {
t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name)
}
}
func TestCreatePARValidationSentinels(t *testing.T) {
svc := &OciConfigService{}
cases := []struct {
name string
in oci.CreatePARInput
want error
}{
{"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType},
{"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration},
{"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in)
if !errors.Is(err, tc.want) {
t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want)
}
}
}
// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求
// 按已排队返回,不并发起第二个清空任务。
func TestDeleteBucketPurgeSingleflight(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
key := fmt.Sprintf("%d/r/b", cfg.ID)
if !svc.beginPurge(key) {
t.Fatal("beginPurge 首次抢占失败")
}
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err)
}
time.Sleep(50 * time.Millisecond)
client.mu.Lock()
calls := client.listCalls
client.mu.Unlock()
if calls != 0 {
t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls)
}
svc.endPurge(key)
if !svc.beginPurge(key) {
t.Error("endPurge 后应可重新抢占")
}
svc.endPurge(key)
}
// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。
func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); 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) {
client.mu.Lock()
done := client.bucketDeleted && client.multipartAborted
client.mu.Unlock()
if done {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("purge 未在期限内完成分片中止与删桶")
}
// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝,
// 不落库也不发起绕过代理的直连测活。
func TestImportRejectsMissingProxy(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
in := trialImportInput()
missing := uint(999)
in.ProxyID = &missing
if _, err := svc.Import(context.Background(), in); err == nil {
t.Fatal("Import 关联不存在的代理应报错")
}
var n int64
if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err)
}
}
+24 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,11 +24,21 @@ type OciConfigService struct {
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟), // auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache auditRaw *cache.Cache
// bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。
bindWG sync.WaitGroup
bindStop chan struct{}
// purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。
purgeMu sync.Mutex
purgeInFlight map[string]bool
} }
// NewOciConfigService 组装依赖。 // NewOciConfigService 组装依赖。
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService { func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)} return &OciConfigService{
db: db, cipher: cipher, client: client,
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
purgeInFlight: map[string]bool{},
}
} }
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。 // SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
@@ -64,6 +75,12 @@ func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.O
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入,
// 避免首次测活绕过代理直连,泄露真实出口
cred.Proxy, err = s.proxySpecOf(in.ProxyID)
if err != nil {
return nil, err
}
cfg, err := s.storeConfig(in.Alias, cred) cfg, err := s.storeConfig(in.Alias, cred)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -420,7 +437,7 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err) return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
} }
} }
spec, err := s.proxySpecOf(cfg) spec, err := s.proxySpecOf(cfg.ProxyID)
if err != nil { if err != nil {
return oci.Credentials{}, err return oci.Credentials{}, err
} }
@@ -435,15 +452,15 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
}, nil }, nil
} }
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。 // proxySpecOf 加载关联的出站代理;未关联返回 nil(直连)。
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。 // 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) { func (s *OciConfigService) proxySpecOf(proxyID *uint) (*oci.ProxySpec, error) {
if cfg.ProxyID == nil { if proxyID == nil {
return nil, nil return nil, nil
} }
var row model.Proxy var row model.Proxy
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil { if err := s.db.First(&row, *proxyID).Error; err != nil {
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err) return nil, fmt.Errorf("find proxy %d: %w", *proxyID, err)
} }
password := "" password := ""
if row.PasswordEnc != "" { if row.PasswordEnc != "" {
+47 -13
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"time" "time"
"oci-portal/internal/model" "oci-portal/internal/model"
@@ -32,14 +33,23 @@ type OverviewCostDay struct {
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// OverviewCost 是近 7 日成本快照聚合金额跨配置直接相加, // OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。
// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。
type OverviewCost struct { type OverviewCost struct {
HasActiveTask bool `json:"hasActiveTask"` HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"` CoveredConfigs int `json:"coveredConfigs"`
Currency string `json:"currency"` Currency string `json:"currency"`
Total float64 `json:"total"` Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"` Days []OverviewCostDay `json:"days"`
// Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个
// (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。
Series []OverviewCostSeries `json:"series"`
}
// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。
type OverviewCostSeries struct {
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
} }
// OverviewTasks 是后台任务数量统计。 // OverviewTasks 是后台任务数量统计。
@@ -150,29 +160,53 @@ func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) erro
return nil return nil
} }
// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序 // aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)
// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的
// 主币种,其余币种只出现在 Series 里。
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) { func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
byDay := map[string]float64{} perCur := map[string]map[string]float64{}
dayOrder := map[string][]string{}
covered := map[uint]bool{} covered := map[uint]bool{}
var order []string
for _, snap := range snaps { for _, snap := range snaps {
byDay := perCur[snap.Currency]
if byDay == nil {
byDay = map[string]float64{}
perCur[snap.Currency] = byDay
}
if _, ok := byDay[snap.Day]; !ok { if _, ok := byDay[snap.Day]; !ok {
order = append(order, snap.Day) dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day)
} }
byDay[snap.Day] += snap.Amount byDay[snap.Day] += snap.Amount
covered[snap.OciConfigID] = true covered[snap.OciConfigID] = true
if cost.Currency == "" {
cost.Currency = snap.Currency
}
} }
cost.CoveredConfigs = len(covered) cost.CoveredConfigs = len(covered)
cost.Days = make([]OverviewCostDay, 0, len(order)) cost.Days = []OverviewCostDay{}
for _, day := range order { cost.Series = costSeries(perCur, dayOrder)
cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]}) if len(cost.Series) > 0 {
cost.Total += byDay[day] cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days
} }
} }
// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。
func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries {
series := make([]OverviewCostSeries, 0, len(perCur))
for currency, byDay := range perCur {
item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))}
for _, day := range dayOrder[currency] {
item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
item.Total += byDay[day]
}
series = append(series, item)
}
sort.Slice(series, func(i, j int) bool {
if series[i].Total != series[j].Total {
return series[i].Total > series[j].Total
}
return series[i].Currency < series[j].Currency
})
return series
}
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error { func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
var tasks []model.Task var tasks []model.Task
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil { if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
+31 -5
View File
@@ -18,12 +18,15 @@ func costItem(day string, amount float32, currency string) oci.CostItem {
} }
func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) { func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
// 日期取相对当前的近两天:任务查近 7 天,窗口外的行会被 Costs 过滤
day1 := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
day2 := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
client := &fakeClient{ client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"},
costItems: []oci.CostItem{ costItems: []oci.CostItem{
costItem("2026-07-01", 5.2, "USD"), costItem(day1, 5.2, "USD"),
costItem("2026-07-01", 1.4, "USD"), costItem(day1, 1.4, "USD"),
costItem("2026-07-02", 6.3, "USD"), costItem(day2, 6.3, "USD"),
}, },
} }
tasks, configs, db := newTaskEnv(t, client) tasks, configs, db := newTaskEnv(t, client)
@@ -55,13 +58,13 @@ func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) {
t.Errorf("log = %+v, want synced 1 skipped 1", entry) t.Errorf("log = %+v, want synced 1 skipped 1", entry)
} }
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3})
// 再次执行为覆盖更新,不产生重复行 // 再次执行为覆盖更新,不产生重复行
if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil {
t.Fatalf("RunTaskNow again: %v", err) t.Fatalf("RunTaskNow again: %v", err)
} }
assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3})
} }
// assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。 // assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。
@@ -167,3 +170,26 @@ func TestOverviewAggregates(t *testing.T) {
t.Errorf("cost days = %d, want 1", len(out.Cost.Days)) t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
} }
} }
// TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加,
// 顶层字段恒为合计最大的主币种,其余币种进 Series。
func TestAggregateCostDaysSplitsCurrencies(t *testing.T) {
snaps := []model.CostSnapshot{
{OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"},
{OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"},
{OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"},
{OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"},
}
var cost OverviewCost
aggregateCostDays(&cost, snaps)
if cost.CoveredConfigs != 3 || len(cost.Series) != 2 {
t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series))
}
if cost.Currency != "EUR" || cost.Total != 9.0 {
t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total)
}
usd := cost.Series[1]
if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 {
t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd)
}
}
+24
View File
@@ -324,3 +324,27 @@ func TestProxySetTenantsRebind(t *testing.T) {
} }
} }
} }
// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃,
// 不得经 Save 回退 Create 复活已删代理。
func TestPersistGeoSkipsDeletedProxy(t *testing.T) {
svc, db := newProxyEnv(t)
ctx := context.Background()
row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed proxy: %v", err)
}
svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"})
var fresh model.Proxy
if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil {
t.Fatalf("正常落库 = %+v, %v", fresh, err)
}
if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil {
t.Fatalf("delete proxy: %v", err)
}
svc.persistGeo(ctx, row.ID, geoResult{Country: "US"})
var n int64
if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err)
}
}
+11 -5
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"time" "time"
@@ -108,13 +109,18 @@ func (s *ProxyService) probeGeo(ctx context.Context, id uint) {
break break
} }
} }
var row model.Proxy s.persistGeo(ctx, id, res)
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return
} }
// persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会
// 回退 Create 把删掉的代理复活;错误也不能静默吞掉。
func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) {
now := time.Now() now := time.Now()
row.Country, row.City, row.GeoAt = res.Country, res.City, &now tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id).
s.db.WithContext(ctx).Save(&row) Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now})
if tx.Error != nil {
log.Printf("proxy geo persist %d: %v", id, tx.Error)
}
} }
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。 // Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
+137
View File
@@ -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):
}
}
}
+150
View File
@@ -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"))
}
+17 -3
View File
@@ -57,7 +57,8 @@ func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint,
rows := make([]model.CompartmentCache, 0, len(comps)) rows := make([]model.CompartmentCache, 0, len(comps))
for _, c := range comps { for _, c := range comps {
rows = append(rows, model.CompartmentCache{ rows = append(rows, model.CompartmentCache{
OciConfigID: cfgID, OCID: c.ID, Name: c.Name, State: c.LifecycleState, OciConfigID: cfgID, OCID: c.ID, Name: c.Name,
ParentOCID: c.ParentID, State: c.LifecycleState,
}) })
} }
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -191,16 +192,29 @@ func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]o
Order("name").Find(&rows).Error; err != nil { Order("name").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("load compartment cache: %w", err) return nil, fmt.Errorf("load compartment cache: %w", err)
} }
if len(rows) == 0 { if len(rows) == 0 || legacyCompartmentCache(rows) {
return s.refreshCompartmentCache(ctx, cfg) return s.refreshCompartmentCache(ctx, cfg)
} }
comps := make([]oci.Compartment, 0, len(rows)) comps := make([]oci.Compartment, 0, len(rows))
for _, r := range rows { for _, r := range rows {
comps = append(comps, oci.Compartment{ID: r.OCID, Name: r.Name, LifecycleState: r.State}) comps = append(comps, oci.Compartment{
ID: r.OCID, Name: r.Name, ParentID: r.ParentOCID, LifecycleState: r.State,
})
} }
return comps, nil return comps, nil
} }
// legacyCompartmentCache 识别未存 parentId 的旧格式缓存:每个 compartment
// 必有父(至少是租户根),整组 parent 全空即旧数据,需实时刷新一次自愈。
func legacyCompartmentCache(rows []model.CompartmentCache) bool {
for _, r := range rows {
if r.ParentOCID != "" {
return false
}
}
return len(rows) > 0
}
// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。 // refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。
func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) { func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) {
cred, err := s.credentialsOf(cfg) cred, err := s.credentialsOf(cfg)
+30 -1
View File
@@ -16,7 +16,7 @@ func multiScopeClient() *fakeClient {
{Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"}, {Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"},
}, },
compartments: []oci.Compartment{ compartments: []oci.Compartment{
{ID: "ocid1.compartment..a", Name: "instances", LifecycleState: "ACTIVE"}, {ID: "ocid1.compartment..a", Name: "instances", ParentID: "ocid1.tenancy..root", LifecycleState: "ACTIVE"},
}, },
} }
} }
@@ -154,6 +154,9 @@ func TestCachedCompartments(t *testing.T) {
if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" { if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" {
t.Errorf("comps = %+v, want 缓存中的 1 项", comps) t.Errorf("comps = %+v, want 缓存中的 1 项", comps)
} }
if comps[0].ParentID != "ocid1.tenancy..root" {
t.Errorf("ParentID = %q, want 缓存透传 parentId", comps[0].ParentID)
}
in := trialImportInput() in := trialImportInput()
in.Alias = "未开启" in.Alias = "未开启"
@@ -170,6 +173,32 @@ func TestCachedCompartments(t *testing.T) {
} }
} }
// TestCachedCompartmentsHealsLegacyRows 旧版缓存(未存 parentId)应实时刷新自愈并回写。
func TestCachedCompartmentsHealsLegacyRows(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importMultiConfig(t, svc)
// 模拟旧版残留:抹掉缓存行的 parentId
if err := svc.db.Model(&model.CompartmentCache{}).Where("oci_config_id = ?", cfg.ID).
Update("ParentOCID", "").Error; err != nil {
t.Fatalf("清空 parentId: %v", err)
}
comps, err := svc.CachedCompartments(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("CachedCompartments: %v", err)
}
if len(comps) != 1 || comps[0].ParentID != "ocid1.tenancy..root" {
t.Errorf("comps = %+v, want 自愈刷新后带 parentId", comps)
}
var rows []model.CompartmentCache
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&rows)
if len(rows) != 1 || rows[0].ParentOCID != "ocid1.tenancy..root" {
t.Errorf("cache rows = %+v, want 缓存已回写 parentId", rows)
}
}
func TestRegionSubscriptionsReconcilesCache(t *testing.T) { func TestRegionSubscriptionsReconcilesCache(t *testing.T) {
client := multiScopeClient() client := multiScopeClient()
svc := newTestService(t, client) svc := newTestService(t, client)
+53 -13
View File
@@ -112,26 +112,66 @@ func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error)
return out, nil return out, nil
} }
// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。 // SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段,
func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error { // 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。
if err := validateSecurity(&in); err != nil { type SecurityPatch struct {
LoginFailLimit *int `json:"loginFailLimit"`
LoginLockMinutes *int `json:"loginLockMinutes"`
IPRateRPS *int `json:"ipRateRps"`
IPRateBurst *int `json:"ipRateBurst"`
RealIPHeader *string `json:"realIpHeader"`
AppURL *string `json:"appUrl"`
}
// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。
func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
touched := map[string]bool{}
fields := []struct {
key string
set func()
on bool
}{
{settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil},
{settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil},
{settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil},
{settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil},
{settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil},
{settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil},
}
for _, f := range fields {
if f.on {
f.set()
touched[f.key] = true
}
}
return touched
}
// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。
func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
cur, err := s.Security(ctx)
if err != nil {
return err
}
touched := p.merge(&cur)
if err := validateSecurity(&cur); err != nil {
return err return err
} }
kv := map[string]string{ kv := map[string]string{
settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit), settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes), settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS), settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst), settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst),
settingSecRealIPHeader: in.RealIPHeader, settingSecRealIPHeader: cur.RealIPHeader,
settingSecAppURL: in.AppURL, settingSecAppURL: cur.AppURL,
} }
for key, value := range kv { for key := range touched {
if err := s.set(ctx, key, value); err != nil { if err := s.set(ctx, key, kv[key]); err != nil {
return err return err
} }
} }
s.security.Store(in) // 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值
return nil return s.ReloadSecurity(ctx)
} }
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。 // ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
+73 -8
View File
@@ -6,6 +6,18 @@ import (
"testing" "testing"
) )
// patchOf 把完整设置转为全字段补丁,等价旧全量保存,供既有用例复用。
func patchOf(in SecuritySettings) SecurityPatch {
return SecurityPatch{
LoginFailLimit: &in.LoginFailLimit,
LoginLockMinutes: &in.LoginLockMinutes,
IPRateRPS: &in.IPRateRPS,
IPRateBurst: &in.IPRateBurst,
RealIPHeader: &in.RealIPHeader,
AppURL: &in.AppURL,
}
}
func TestSecurityReadWrite(t *testing.T) { func TestSecurityReadWrite(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -21,7 +33,7 @@ func TestSecurityReadWrite(t *testing.T) {
name: "写入后读回", name: "写入后读回",
setup: func(t *testing.T, svc *SettingService) { setup: func(t *testing.T, svc *SettingService) {
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"} in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
if err := svc.UpdateSecurity(context.Background(), in); err != nil { if err := svc.UpdateSecurity(context.Background(), patchOf(in)); err != nil {
t.Fatalf("UpdateSecurity: %v", err) t.Fatalf("UpdateSecurity: %v", err)
} }
}, },
@@ -76,7 +88,7 @@ func TestUpdateSecurityRejectsInvalid(t *testing.T) {
svc, _ := newSettingEnv(t) svc, _ := newSettingEnv(t)
in := base in := base
tt.mutate(&in) tt.mutate(&in)
if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) { if err := svc.UpdateSecurity(context.Background(), patchOf(in)); !errors.Is(err, ErrInvalidSecurity) {
t.Fatalf("err = %v, want ErrInvalidSecurity", err) t.Fatalf("err = %v, want ErrInvalidSecurity", err)
} }
}) })
@@ -89,9 +101,7 @@ func TestSecurityCachedSnapshot(t *testing.T) {
if got := svc.SecurityCached(); got != securityDefaults { if got := svc.SecurityCached(); got != securityDefaults {
t.Errorf("cold cache = %+v, want defaults", got) t.Errorf("cold cache = %+v, want defaults", got)
} }
in := securityDefaults if err := svc.UpdateSecurity(context.Background(), SecurityPatch{LoginFailLimit: intPtr(8)}); err != nil {
in.LoginFailLimit = 8
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
t.Fatalf("UpdateSecurity: %v", err) t.Fatalf("UpdateSecurity: %v", err)
} }
// Update 即刷新快照 // Update 即刷新快照
@@ -110,12 +120,67 @@ func TestEffectiveAppURL(t *testing.T) {
if got := svc.EffectiveAppURL(); got != "https://env.example.com" { if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
t.Errorf("env fallback = %q", got) t.Errorf("env fallback = %q", got)
} }
in := securityDefaults if err := svc.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
in.AppURL = "https://app.example.com"
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
t.Fatalf("UpdateSecurity: %v", err) t.Fatalf("UpdateSecurity: %v", err)
} }
if got := svc.EffectiveAppURL(); got != "https://app.example.com" { if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
t.Errorf("app_url 应优先, got %q", got) t.Errorf("app_url 应优先, got %q", got)
} }
} }
// TestUpdateSecurityPartialPatch 锁定 PATCH 语义:只写出现字段,其余不回滚。
func TestUpdateSecurityPartialPatch(t *testing.T) {
seeded := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://a.example.com"}
tests := []struct {
name string
patch SecurityPatch
want SecuritySettings
wantErr bool
}{
{
name: "单字段更新不动其余",
patch: SecurityPatch{LoginFailLimit: intPtr(3)},
want: func() SecuritySettings {
w := seeded
w.LoginFailLimit = 3
return w
}(),
},
{name: "空补丁不改任何值", patch: SecurityPatch{}, want: seeded},
{
name: "补丁字段规范化(AppURL 去尾斜杠)",
patch: SecurityPatch{AppURL: strPtr("https://b.example.com/")},
want: func() SecuritySettings {
w := seeded
w.AppURL = "https://b.example.com"
return w
}(),
},
{name: "补丁字段越界拒绝", patch: SecurityPatch{IPRateRPS: intPtr(0)}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
if err := svc.UpdateSecurity(context.Background(), patchOf(seeded)); err != nil {
t.Fatalf("seed: %v", err)
}
err := svc.UpdateSecurity(context.Background(), tt.patch)
if tt.wantErr {
if !errors.Is(err, ErrInvalidSecurity) {
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
}
return
}
if err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
got, err := svc.Security(context.Background())
if err != nil || got != tt.want {
t.Errorf("settings = %+v (%v), want %+v", got, err, tt.want)
}
if cached := svc.SecurityCached(); cached != tt.want {
t.Errorf("cached = %+v, want %+v", cached, tt.want)
}
})
}
}
+22 -1
View File
@@ -81,8 +81,29 @@ func (s *SettingService) get(ctx context.Context, key string) (string, error) {
// set 写入键值(key 为主键,Save 即 upsert)。 // set 写入键值(key 为主键,Save 即 upsert)。
func (s *SettingService) set(ctx context.Context, key, value string) error { func (s *SettingService) set(ctx context.Context, key, value string) error {
if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil {
return err
}
return nil
}
// settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种
// 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。
func settingValueTx(tx *gorm.DB, key string) (string, error) {
var st model.Setting
err := tx.First(&st, "key = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get setting %s: %w", key, err)
}
return st.Value, nil
}
func saveSettingTx(tx *gorm.DB, key, value string) error {
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()} st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil { if err := tx.Save(&st).Error; err != nil {
return fmt.Errorf("set setting %s: %w", key, err) return fmt.Errorf("set setting %s: %w", key, err)
} }
return nil return nil
+4
View File
@@ -41,6 +41,10 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
func strPtr(s string) *string { return &s } func strPtr(s string) *string { return &s }
func intPtr(n int) *int { return &n }
func boolPtr(b bool) *bool { return &b }
func TestUpdateTelegramTokenHandling(t *testing.T) { func TestUpdateTelegramTokenHandling(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+74 -8
View File
@@ -202,6 +202,38 @@ func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMe
return out return out
} }
// mergeSnatchPayload 把编辑提交的 count 视为**新目标台数**:按旧 payload 的
// 已完成数换算剩余、保留连续鉴权失败计数,目标不大于已完成数时拒绝。
// 非抢机任务原样返回;旧 payload 不可解析时按新建任务归一化。
func mergeSnatchPayload(task *model.Task, incoming json.RawMessage) (json.RawMessage, error) {
if task.Type != model.TaskTypeSnatch {
return incoming, nil
}
var old, next snatchPayload
if err := json.Unmarshal([]byte(task.Payload), &old); err != nil {
return normalizeSnatchPayload(task.Type, incoming), nil
}
if err := json.Unmarshal(incoming, &next); err != nil {
return nil, fmt.Errorf("update task: invalid payload: %w", err)
}
oldTotal := old.TotalCount
if oldTotal <= 0 {
oldTotal = old.Count
}
done := oldTotal - old.Count
if next.Count <= done {
return nil, fmt.Errorf("%w(已完成 %d 台)", ErrSnatchTargetTooLow, done)
}
next.TotalCount = next.Count
next.Count -= done
next.AuthFailCount = old.AuthFailCount
out, err := json.Marshal(next)
if err != nil {
return nil, fmt.Errorf("update task: marshal payload: %w", err)
}
return out, nil
}
// validateTaskPayload 按任务类型校验参数 JSON。 // validateTaskPayload 按任务类型校验参数 JSON。
func validateTaskPayload(taskType string, payload json.RawMessage) error { func validateTaskPayload(taskType string, payload json.RawMessage) error {
switch taskType { switch taskType {
@@ -245,7 +277,8 @@ type UpdateTaskInput struct {
Status *string Status *string
} }
// UpdateTask 修改任务并重新调度 // UpdateTask 修改任务并重新调度;写入用 updated_at 条件更新,
// 防止陈旧快照整行覆盖并发执行刚落库的状态与进度。
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) { func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
task, err := s.GetTask(ctx, id) task, err := s.GetTask(ctx, id)
if err != nil { if err != nil {
@@ -254,16 +287,39 @@ func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInpu
if err := applyTaskUpdate(task, in); err != nil { if err := applyTaskUpdate(task, in); err != nil {
return nil, err return nil, err
} }
if err := s.db.WithContext(ctx).Save(task).Error; err != nil { fresh, err := s.persistTaskUpdate(ctx, task)
return nil, fmt.Errorf("update task %d: %w", id, err) if err != nil {
return nil, err
} }
s.unschedule(task.ID) s.unschedule(fresh.ID)
if task.Status == model.TaskStatusActive { if fresh.Status == model.TaskStatusActive {
if err := s.schedule(task); err != nil { if err := s.schedule(fresh); err != nil {
return nil, err return nil, err
} }
} }
return task, nil return fresh, nil
}
// persistTaskUpdate 只写用户可编辑列;零命中说明执行侧已并发落库,返回冲突。
func (s *TaskService) persistTaskUpdate(ctx context.Context, task *model.Task) (*model.Task, error) {
res := s.db.WithContext(ctx).Model(&model.Task{}).
Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).
Updates(map[string]any{
"name": task.Name, "cron_expr": task.CronExpr,
"payload": task.Payload, "status": task.Status,
})
if res.Error != nil {
return nil, fmt.Errorf("update task %d: %w", task.ID, res.Error)
}
if res.RowsAffected == 0 {
return nil, ErrTaskConflict
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.Task
if err := s.db.WithContext(ctx).First(&fresh, task.ID).Error; err != nil {
return nil, fmt.Errorf("reload task %d: %w", task.ID, err)
}
return &fresh, nil
} }
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error { func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
@@ -280,7 +336,11 @@ func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
if err := validateTaskPayload(task.Type, in.Payload); err != nil { if err := validateTaskPayload(task.Type, in.Payload); err != nil {
return err return err
} }
task.Payload = string(in.Payload) merged, err := mergeSnatchPayload(task, in.Payload)
if err != nil {
return err
}
task.Payload = string(merged)
} }
if in.Status != nil { if in.Status != nil {
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused { if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
@@ -376,6 +436,12 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog,
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。 // ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
var ErrTaskRunning = errors.New("任务正在执行中,请稍候") var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
// ErrTaskConflict 表示任务在编辑期间被并发修改(如执行结果落库),须刷新重试。
var ErrTaskConflict = errors.New("任务状态已变化,请刷新后重试")
// ErrSnatchTargetTooLow 表示编辑抢机任务时新目标台数不大于已完成数量。
var ErrSnatchTargetTooLow = errors.New("目标台数须大于已完成数量")
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知, // TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。 // 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error { func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
+91
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -835,3 +836,93 @@ func TestTriggerTaskAsyncAndDedup(t *testing.T) {
t.Error("不存在的任务应报错") t.Error("不存在的任务应报错")
} }
} }
// snatchInstanceJSON 是编辑测试用的合法实例参数片段。
const snatchInstanceJSON = `"instance":{"displayName":"vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}`
// TestUpdateSnatchTaskCountIsTarget 锁定编辑语义:提交的 count 是目标台数,
// 按旧 payload 已完成数换算剩余、保留鉴权失败计数;目标不大于已完成数拒绝。
func TestUpdateSnatchTaskCountIsTarget(t *testing.T) {
tests := []struct {
name string
oldPayload string
newCount int
wantErr error
want snatchPayload
}{
{"部分成功后提高目标", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
6, nil, snatchPayload{Count: 4, TotalCount: 6, AuthFailCount: 2}},
{"原样保存不丢进度", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
5, nil, snatchPayload{Count: 3, TotalCount: 5, AuthFailCount: 2}},
{"目标不大于已完成拒绝", `{"ociConfigId":1,"count":3,"totalCount":5,` + snatchInstanceJSON + `}`,
2, ErrSnatchTargetTooLow, snatchPayload{}},
{"旧任务缺 totalCount 视为零完成", `{"ociConfigId":1,"count":3,` + snatchInstanceJSON + `}`,
5, nil, snatchPayload{Count: 5, TotalCount: 5}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tasks, _, db := newTaskEnv(t, &fakeClient{})
ctx := context.Background()
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(tt.oldPayload),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// 直写旧 payload,绕过创建期归一化,构造「执行过若干轮」的现场
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).
Update("payload", tt.oldPayload).Error; err != nil {
t.Fatalf("seed payload: %v", err)
}
incoming := fmt.Sprintf(`{"ociConfigId":1,"count":%d,`+snatchInstanceJSON+`}`, tt.newCount)
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Payload: json.RawMessage(incoming)})
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("UpdateTask: %v", err)
}
var got snatchPayload
if err := json.Unmarshal([]byte(updated.Payload), &got); err != nil {
t.Fatalf("payload: %v", err)
}
if got.Count != tt.want.Count || got.TotalCount != tt.want.TotalCount || got.AuthFailCount != tt.want.AuthFailCount {
t.Errorf("payload = {count:%d total:%d authFail:%d}, want {count:%d total:%d authFail:%d}",
got.Count, got.TotalCount, got.AuthFailCount, tt.want.Count, tt.want.TotalCount, tt.want.AuthFailCount)
}
})
}
}
// TestPersistTaskUpdateConflict 锁定编辑竞态防护:陈旧快照零命中返回冲突且不落库。
func TestPersistTaskUpdateConflict(t *testing.T) {
tasks, _, db := newTaskEnv(t, &fakeClient{})
ctx := context.Background()
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
stale, err := tasks.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
// 模拟执行侧并发落库:行的 updated_at 前移,编辑快照过期
time.Sleep(5 * time.Millisecond)
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("run_count", 1).Error; err != nil {
t.Fatalf("simulate run persist: %v", err)
}
stale.Name = "renamed"
if _, err := tasks.persistTaskUpdate(ctx, stale); !errors.Is(err, ErrTaskConflict) {
t.Fatalf("err = %v, want ErrTaskConflict", err)
}
fresh, _ := tasks.GetTask(ctx, task.ID)
if fresh.Name != "测活" || fresh.RunCount != 1 {
t.Errorf("row = {name:%s runCount:%d}, want 执行结果保留且未被改名", fresh.Name, fresh.RunCount)
}
}
+10 -14
View File
@@ -231,11 +231,10 @@ func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
if len(channelIDs) == 0 { if len(channelIDs) == 0 {
return nil return nil
} }
callIDs, err := lockedAiCallIDs(tx, channelIDs) if err := lockAiCallRows(tx, channelIDs); err != nil {
if err != nil {
return fmt.Errorf("load tenant AI calls: %w", err) return fmt.Errorf("load tenant AI calls: %w", err)
} }
if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil { if err := deleteTenantAIRows(tx, channelIDs); err != nil {
return err return err
} }
result.channelsGone = true result.channelsGone = true
@@ -253,23 +252,20 @@ func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) {
return ids, err return ids, err
} }
func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) { // lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略,
// MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL,
// 内容日志删除用子查询,避免绑定变量上限。
func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error {
var rows []model.AiCallLog var rows []model.AiCallLog
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids, err
} }
func deleteTenantAIRows(tx *gorm.DB, channelIDs, callIDs []uint) error { func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error {
if len(callIDs) > 0 { callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs)
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil { if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil {
return fmt.Errorf("delete tenant AI content logs: %w", err) return fmt.Errorf("delete tenant AI content logs: %w", err)
} }
}
steps := []struct { steps := []struct {
name string name string
value any value any
+73 -5
View File
@@ -224,7 +224,7 @@ func TestOAuthProvidersListsConfigured(t *testing.T) {
t.Fatalf("未配置时 providers = %v", got) t.Fatalf("未配置时 providers = %v", got)
} }
secret := "gh-secret" secret := "gh-secret"
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret}) err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret})
if err != nil { if err != nil {
t.Fatalf("UpdateOAuth: %v", err) t.Fatalf("UpdateOAuth: %v", err)
} }
@@ -249,7 +249,7 @@ func TestOAuthAuthorizeConfigErrors(t *testing.T) {
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err) t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
} }
// clientID 已配但面板地址未设置 // clientID 已配但面板地址未设置
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil { if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}); err != nil {
t.Fatalf("UpdateOAuth: %v", err) t.Fatalf("UpdateOAuth: %v", err)
} }
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) { if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) {
@@ -273,7 +273,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("Providers = %v, want empty", got) t.Fatalf("Providers = %v, want empty", got)
} }
// 配置 github(无显示名称)→ 默认名 GitHub // 配置 github(无显示名称)→ 默认名 GitHub
in := UpdateOAuthInput{GithubClientID: "Iv1.test"} in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}
if err := o.settings.UpdateOAuth(ctx, in); err != nil { if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err) t.Fatalf("UpdateOAuth: %v", err)
} }
@@ -282,7 +282,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("Providers = %+v, want [github/GitHub]", got) t.Fatalf("Providers = %+v, want [github/GitHub]", got)
} }
// 自定义显示名称 // 自定义显示名称
in.GithubDisplayName = "公司账号" in.GithubDisplayName = strPtr("公司账号")
if err := o.settings.UpdateOAuth(ctx, in); err != nil { if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err) t.Fatalf("UpdateOAuth: %v", err)
} }
@@ -290,7 +290,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName) t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
} }
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起 // 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
in.GithubDisabled = true in.GithubDisabled = boolPtr(true)
if err := o.settings.UpdateOAuth(ctx, in); err != nil { if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err) t.Fatalf("UpdateOAuth: %v", err)
} }
@@ -309,3 +309,71 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("OAuthView = %+v, %v", view, err) t.Fatalf("OAuthView = %+v, %v", view, err)
} }
} }
// TestUpdateOAuthPartialPatch 锁定 provider 配置的部分更新语义:
// 只写出现字段,另一 provider 与未出现字段不回滚,secret nil 沿用空串清除。
func TestUpdateOAuthPartialPatch(t *testing.T) {
o, _ := newOAuthEnv(t)
ctx := context.Background()
secret := "gh-secret"
seed := UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret, GithubDisplayName: strPtr("公司账号")}
if err := o.settings.UpdateOAuth(ctx, seed); err != nil {
t.Fatalf("seed: %v", err)
}
steps := []struct {
name string
patch UpdateOAuthInput
check func(v OAuthProvidersView) string
}{
{
name: "只更新 oidc 不动 github",
patch: UpdateOAuthInput{OidcIssuer: strPtr("https://sso.example.com/"), OidcClientID: strPtr("oidc-cid")},
check: func(v OAuthProvidersView) string {
if v.OidcIssuer != "https://sso.example.com" || v.OidcClientID != "oidc-cid" {
return "oidc 字段未生效或未规范化"
}
if v.GithubClientID != "cid" || !v.GithubSecretSet || v.GithubDisplayName != "公司账号" {
return "github 字段被回滚"
}
return ""
},
},
{
name: "单开关启停,secret nil 沿用",
patch: UpdateOAuthInput{GithubDisabled: boolPtr(true)},
check: func(v OAuthProvidersView) string {
if !v.GithubDisabled || v.GithubClientID != "cid" || !v.GithubSecretSet {
return "启停外字段被动到或 secret 丢失"
}
return ""
},
},
{
name: "secret 空串清除",
patch: UpdateOAuthInput{GithubClientSecret: strPtr("")},
check: func(v OAuthProvidersView) string {
if v.GithubSecretSet {
return "空串未清除 secret"
}
if v.GithubClientID != "cid" {
return "clientID 被动到"
}
return ""
},
},
}
for _, st := range steps {
t.Run(st.name, func(t *testing.T) {
if err := o.settings.UpdateOAuth(ctx, st.patch); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
view, err := o.settings.OAuthView(ctx)
if err != nil {
t.Fatalf("OAuthView: %v", err)
}
if msg := st.check(view); msg != "" {
t.Error(msg)
}
})
}
}
+30 -3
View File
@@ -45,7 +45,28 @@ func (s *OciConfigService) Costs(ctx context.Context, id uint, q oci.CostQuery)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.client.SummarizeCosts(ctx, cred, q) items, err := s.client.SummarizeCosts(ctx, cred, q)
if err != nil {
return nil, err
}
return filterCostWindow(items, q.StartTime, q.EndTime), nil
}
// filterCostWindow 只保留落在请求窗口 [start, end) 内的行:Usage API 的
// HOURLY 粒度会把起点下扩到 UTC 日零点,返回窗口之外的整日数据。
func filterCostWindow(items []oci.CostItem, start, end time.Time) []oci.CostItem {
out := items[:0]
for _, it := range items {
if it.TimeStart == nil {
continue
}
ts := it.TimeStart.UTC()
if ts.Before(start) || !ts.Before(end) {
continue
}
out = append(out, it)
}
return out
} }
// applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。 // applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。
@@ -73,8 +94,11 @@ func applyCostDefaults(q *oci.CostQuery) {
func alignDown(t time.Time, granularity string) time.Time { func alignDown(t time.Time, granularity string) time.Time {
t = t.UTC() t = t.UTC()
if granularity == "MONTHLY" { switch granularity {
case "MONTHLY":
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
case "HOURLY":
return t.Truncate(time.Hour)
} }
return t.Truncate(24 * time.Hour) return t.Truncate(24 * time.Hour)
} }
@@ -84,8 +108,11 @@ func alignUp(t time.Time, granularity string) time.Time {
if down.Equal(t.UTC()) { if down.Equal(t.UTC()) {
return down return down
} }
if granularity == "MONTHLY" { switch granularity {
case "MONTHLY":
return down.AddDate(0, 1, 0) return down.AddDate(0, 1, 0)
case "HOURLY":
return down.Add(time.Hour)
} }
return down.AddDate(0, 0, 1) return down.AddDate(0, 0, 1)
} }
+34
View File
@@ -101,6 +101,40 @@ func TestApplyCostDefaults(t *testing.T) {
} }
} }
func TestFilterCostWindow(t *testing.T) {
hour := func(h int) *time.Time {
ts := time.Date(2026, 7, 20, h, 0, 0, 0, time.UTC)
return &ts
}
start := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC)
end := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
items []oci.CostItem
want int
}{
{name: "空输入", items: []oci.CostItem{}, want: 0},
{name: "窗口前的整日下扩数据被丢弃", items: []oci.CostItem{{TimeStart: hour(0)}, {TimeStart: hour(15)}}, want: 0},
{name: "起点边界含", items: []oci.CostItem{{TimeStart: hour(16)}}, want: 1},
{name: "终点边界不含", items: []oci.CostItem{{TimeStart: &end}}, want: 0},
{name: "TimeStart 缺失丢弃", items: []oci.CostItem{{TimeStart: nil}}, want: 0},
{name: "窗口内外混合只留窗口内", items: []oci.CostItem{{TimeStart: hour(2)}, {TimeStart: hour(16)}, {TimeStart: hour(23)}, {TimeStart: &end}}, want: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterCostWindow(tt.items, start, end)
if len(got) != tt.want {
t.Errorf("filterCostWindow() kept %d rows, want %d", len(got), tt.want)
}
for _, it := range got {
if it.TimeStart.Before(start) || !it.TimeStart.Before(end) {
t.Errorf("row %v outside window [%v, %v)", it.TimeStart, start, end)
}
}
})
}
}
func TestValidateEmails(t *testing.T) { func TestValidateEmails(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

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