3 Commits
Author SHA1 Message Date
wangdefa 489cb49cb3 AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s
2026-07-12 17:48:28 +08:00
wangdefa 7706f59549 发布 0.2.0:模型池自愈、探测修正、任务异步触发与删除加固
CI / test (push) Successful in 32s
Release / release (push) Successful in 52s
2026-07-10 20:25:37 +08:00
Wang Defa dbba1f4905 发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
2026-07-10 17:38:34 +08:00
103 changed files with 10076 additions and 3157 deletions
+10 -8
View File
@@ -23,19 +23,20 @@ jobs:
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: 下载前端最新 dist.zip 解压进嵌入目录
- name: 下载前端 dist.zip
env:
TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
DASH_TAG=$(curl -fsSL -H "Authorization: token ${TOKEN}" \
"${{ gitea.server_url }}/api/v1/repos/${DASH_REPO}/releases/latest" | jq -r '.tag_name')
curl -fsSL -H "Authorization: token ${TOKEN}" \
-o dist.zip "${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}/dist.zip"
DASH_TAG=$(head -1 DASH_VERSION)
BASE="${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}"
curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip "${BASE}/dist.zip"
curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip.sha256 "${BASE}/dist.zip.sha256"
sha256sum -c dist.zip.sha256
rm -rf internal/webui/dist && mkdir -p internal/webui/dist
unzip -q dist.zip -d internal/webui/dist
test -f internal/webui/dist/index.html
- name: 构建双架构二进制(artifact stage 导出)
- name: 构建双架构二进制
run: |
docker buildx build --pull \
--platform linux/amd64,linux/arm64 \
@@ -46,8 +47,9 @@ jobs:
mkdir -p assets
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
(cd assets && sha256sum * > SHA256SUMS)
- name: 构建并推送多平台镜像(与上一步共享构建缓存)
- name: 构建并推送多平台镜像
env:
TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
@@ -65,7 +67,7 @@ jobs:
awk -v ver="${TAG#v}" '$0 ~ "^## \\[" ver "\\]" {flag=1; next} /^## \[/ && flag {exit} flag {print}' CHANGELOG.md > release-notes.md
[ -s release-notes.md ] || echo "Release ${TAG}" > release-notes.md
- name: 创建 Release 并上传二进制附件(重跑幂等,同名附件自动替换)
- name: 创建 Release 并上传二进制附件
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.BUILD_TOKEN }}
+9 -4
View File
@@ -10,7 +10,7 @@ permissions:
env:
BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
DASH_DOWNLOAD: https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip
DASH_REPO: wangdefaa/oci-portal-dash
IMAGE: ghcr.io/wangdefaa/oci-portal
jobs:
@@ -21,14 +21,18 @@ jobs:
- uses: docker/setup-buildx-action@v3
- name: 下载前端最新 dist.zip 解压进嵌入目录
- name: 下载前端 dist.zip
run: |
curl -fsSL -o dist.zip "$DASH_DOWNLOAD"
DASH_TAG=$(head -1 DASH_VERSION)
BASE="https://github.com/${DASH_REPO}/releases/download/${DASH_TAG}"
curl -fsSL -o dist.zip "${BASE}/dist.zip"
curl -fsSL -o dist.zip.sha256 "${BASE}/dist.zip.sha256"
sha256sum -c dist.zip.sha256
rm -rf internal/webui/dist && mkdir -p internal/webui/dist
unzip -q dist.zip -d internal/webui/dist
test -f internal/webui/dist/index.html
- name: 构建双架构二进制(artifact stage 导出)
- name: 构建双架构二进制
run: |
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV"
@@ -41,6 +45,7 @@ jobs:
mkdir -p assets
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
(cd assets && sha256sum * > SHA256SUMS)
ls -lh assets/
- name: 提取 CHANGELOG 版本段作为 Release 描述
+2 -1
View File
@@ -11,7 +11,8 @@
"env": {
"DATA_KEY": "dev-data-key",
"JWT_SECRET": "dev-jwt-secret",
"ADMIN_PASSWORD": "admin123"
"ADMIN_PASSWORD": "admin123",
"GIN_MODE": "debug"
}
},
{
+55
View File
@@ -2,6 +2,61 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
## [0.2.0] - 2026-07-12
### Added
- AI 网关模型黑名单:面板集中拉黑模型,全渠道剔除(模型列表 / 路由 / 探测候选不再出现,同步不入库),移除条目并重新同步后恢复;新表 `ai_model_blacklists`(启动自动迁移),管理接口 `/api/v1/ai-blacklist`
- AI 密钥模型白名单:密钥可限定放行的模型列表(空 = 不限),白名单外调用 404,模型列表只返回交集
- 推理力度(effort)直通:Responses `reasoning.effort` 与 Anthropic Messages `output_config.effort` 原样透传上游、不做档位校验;grok-4.3 五档(`none` 完全关闭思考)、gpt-oss `minimal`-`high`、multi-agent `xhigh`(控制并行 agent 数)实测生效
- xAI Grok 服务端工具:`/ai/v1/responses``tools` 支持 `{"type":"web_search"}` / `{"type":"x_search"}`(官方 Agent Tools 格式,可与 `function` 混用,仅非流式),响应保留 `web_search_call` 输出项与引用
### Changed
- **AI 网关对话链路整体切换到 OCI OpenAI 兼容面**`/actions/v1/responses`),typed chat 面全链路移除:`/ai/v1/responses` 请求体原样直通(仅强制 `store:false`),流式为上游原生事件流,gpt-oss 推理增量直达客户端;`/ai/v1/messages` 转换为 Responses 请求直通,流式经事件桥转回标准 Anthropic 事件序列,`stop_sequences` / `top_k` 等无兼容面对应物的字段改为静默忽略,思考输出不转 `thinking` 块;渠道探测改用兼容面最小请求;流式建立后绑定渠道,中断不重试不计熔断
- 后台任务「立即执行」改异步触发:接口立即返回 202,执行结果经任务日志轮询呈现;同一任务在途时重复触发返回 409「任务正在执行中」,与 cron 重叠触发静默跳过(此前同步阻塞数十秒且同一任务可并发重复执行)
- 渠道探测候选跨厂商分散(上限 8,voice 等不可对话形态排除):单一厂商在区域内全为微调基座时不再拖垮整个渠道的探测结论
- 网关调用遇「模型不可按需调用」类错误(微调基座 400 / 实体不存在 404)自动换渠道重试且不计入渠道熔断,错误信息带模型名便于加入黑名单;仅鉴权类错误才判定租户无配额
- 同名模型多条目去重优先保留非微调基座条目,降低缓存到不可调用 OCID 的概率
### Removed
- **`/ai/v1/chat/completions` 端点移除**(404):OpenAI 官方新能力已集中到 Responses 且本面板真实流量为零,请迁移 `/ai/v1/responses``/ai/v1/messages`
- google / cohere 对话模型不再供给(OCI OpenAI 兼容面不承接,上游 400cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
### Fixed
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
## [0.1.0] - 2026-07-10
### Added
- 通知渠道扩展:WebhookSlack / 飞书 / 钉钉 / 企微预设)、ntfy、Bark、SMTP 邮件与 Telegram 五渠道并存,逐渠道推送互不影响,密钥加密落库
- 审计告警规则:按租户 / 事件 / 资源 / 来源 IPCIDR in|notin)组合匹配,阈值窗口计数与冷却,命中即推送模板通知
- 撤销全部会话:`POST /auth/revoke-sessions` 使全部旧令牌立即失效,响应携带操作者的新令牌
- 租户暂停状态识别:测活经账户 capabilities 判定 `suspended`,独立于存活 / 失效展示
- 抢机成功通知补全实例明细:租户别名、区域别名、类型与配置、镜像、公网 IPv4 与 root 密码
### Changed
- 敏感身份操作(修改凭据、TOTP 开关、外部身份绑定 / 解绑、密码登录开关)成功后令牌版本递增:全部旧 JWT 立即失效,响应返回新令牌无感续期(原 204 改为 200 + token
- 管理员徽标与管理员组识别支持双候选组名(Administrators / OCI_Administrators
- Release 流程:前端产物版本由 `DASH_VERSION` 显式固定并做 sha256 校验,二进制附 `SHA256SUMS`docker-compose 示例默认只绑定 `127.0.0.1`
- README 新增「反向代理」小节(Caddy / nginx / Traefik 示例与路由前缀清单)
### Fixed
- 删除租户改为单事务清理全部关联本地数据(任务及日志、测活 / 成本快照、回传事件、告警规则、AI 渠道、Webhook 密钥),等待在途任务执行结束,迟到写入不再残留孤儿数据
### Security
- 实例 root 密码收敛:实例详情默认掩码、点击查看,串口控制台不再提供「复制 root 密码」
- JWT 令牌版本机制:凭据 / TOTP / 外部身份 / 登录策略变更即失效全部旧令牌
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
## [0.0.1] - 2026-07-09
首个版本:自托管 OCI 多租户管理面板后端。
+1
View File
@@ -0,0 +1 @@
v0.2.0
+163 -5
View File
@@ -34,11 +34,11 @@
- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额与成本查询,多区域/多区间支持
- **抢机任务**:cron 周期尝试创建实例直到成功,支持熔断与通知
- **网页控制台**:实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道
- **AI 网关**OpenAI / Anthropic 兼容端点转发 OCI GenAI(对话/Responses/Embeddings),号池渠道加权负载均衡、熔断探测、密钥管理与用量日志
- **日志回传**OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路
- **租户治理**IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人
- **安全**JWT + bcrypt、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、系统操作审计
- **通知**Telegram 模板化推送(测活/抢机/成本/锁定等事件)
- **AI 网关**OpenAI Responses / Anthropic Messages / Embeddings 兼容端点转发 OCI GenAI,号池渠道加权负载均衡、熔断探测、密钥管理与用量日志;对话统一走 OCI OpenAI 兼容面(xai / meta / openai 厂商模型,流式事件与推理增量原样保真),支持 xAI Grok 服务端工具 `web_search` / `x_search`(格式同 xAI 官方 Agent Tools,仅非流式)——兼容面属实测可用但未见 Oracle 文档承诺的能力,行为可能随上游调整
- **日志回传**OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路;自定义告警规则(事件类型/来源 IP 白名单/资源/频率阈值)命中即推送
- **租户治理**IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人;多 Identity Domain 租户可按域切换管理
- **安全**JWT + bcrypt(凭据变更旧令牌立即失效,可一键撤销全部会话)、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、请求体/超时防护、系统操作审计
- **通知**模板化推送,五渠道并存(Telegram / Webhook / ntfy / Bark / SMTP),Webhook 可对接飞书、钉钉、Slack、企业微信机器人
## 快速开始
@@ -55,9 +55,13 @@ DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWO
### Docker Compose
```bash
# 镜像以 distroless nonroot(uid 65532)运行,数据卷需可写,否则 SQLite 报 unable to open database file
mkdir -p ./data && sudo chown 65532:65532 ./data
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d
```
默认只监听 `127.0.0.1:18888`;公网访问须置于 TLS 反向代理之后,见下文「反向代理」。
### 源码构建(单文件,含前端)
```bash
@@ -72,6 +76,159 @@ CGO_ENABLED=0 go build -trimpath \
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。
### 反向代理(公网部署必读)
面板自身只提供 HTTP,管理员口令、JWT 与租户 API 私钥都会经明文承载。除本机试用外,应让面板仅监听回环地址(compose 示例已默认 `127.0.0.1:18888`),由支持 TLS 的反向代理对外提供 HTTPS。
Caddy 最小示例(整站反代,自动申请并续期 Let's Encrypt 证书,WebSocket 自动透传):
```caddyfile
portal.example.com {
reverse_proxy 127.0.0.1:18888
}
```
nginx 示例(证书自备;Web Console 的 WebSocket 升级与长超时必须显式配置,否则串行终端连不上或空闲即断):
```nginx
# http 块内:按请求是否升级为 WebSocket 决定 Connection 头
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
# 放行到最大入口(AI 网关 10MB);各入口更细的上限由后端自身执行
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:18888;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket 空闲与 AI 流式响应都需要长读超时(默认 60s 会断)
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
```
Traefik 示例(已运行 Traefik 的 Docker 环境,面板容器加 label 接入即可,WebSocket 透明支持):
```yaml
services:
oci-portal:
image: ghcr.io/wangdefaa/oci-portal:latest
# ……环境变量与数据卷同上文 compose 示例;与 Traefik 同一 docker 网络,
# Traefik 经容器网络直达 8080,无需(也不应)对外映射 ports
labels:
- traefik.enable=true
- traefik.http.routers.oci-portal.rule=Host(`portal.example.com`)
- traefik.http.routers.oci-portal.entrypoints=websecure
- traefik.http.routers.oci-portal.tls.certresolver=le # 换成你的 certResolver 名
- traefik.http.services.oci-portal.loadbalancer.server.port=8080
```
若前端静态文件由反代直接伺服(不走内嵌页面),则按前缀代理,缺一不可:
| 前缀 | 内容 | 何时需要 |
| --- | --- | --- |
| `/api/*` | 面板 REST、Web Console WebSocket、日志回传 webhook | 始终 |
| `/ai/*` | AI 网关(OpenAI / Claude 兼容端点,独立密钥鉴权) | 启用 AI 网关时 |
| 其余路径 | 前端 SPA 静态文件(404 回退 `index.html` | 静态分离形态 |
- 反代默认追加的 `X-Forwarded-For` 用于还原真实客户端 IP,系统日志留痕、登录锁定与 IP 限速都依赖它
- 请求体上限建议与后端一致:`/api/*` 1MB、`/ai/*` 10MBCaddy 用 `request_body` 按前缀分层)
## AI 网关接口
对外提供 OpenAI / Anthropic 兼容端点,转发 OCI GenAI On-Demand 推理。鉴权用面板创建的 AI 密钥,`Authorization: Bearer sk-...``x-api-key: sk-...` 双头均可;密钥可绑定渠道分组实现路由隔离,也可配置模型白名单(白名单外调用 404,模型列表只返回交集)。请求体上限 10MB。
| 端点 | 协议 | 流式 |
| --- | --- | --- |
| `POST /ai/v1/responses` | OpenAI Responses(无状态子集) | SSE 支持(服务端工具除外) |
| `POST /ai/v1/messages` | Anthropic Messages | SSE 支持 |
| `POST /ai/v1/embeddings` | OpenAI Embeddings | — |
| `GET /ai/v1/models` | OpenAI 模型列表(来自渠道模型目录) | — |
对话端点统一转发 OCI OpenAI 兼容面(`/actions/v1/responses`),因此仅提供该面支持的 `xai.` / `meta.` / `openai.` 前缀模型;google / cohere 对话模型不在兼容面供给(上游 400),不再提供(cohere embed 模型不受影响)。兼容面属实测可用但无 Oracle 文档承诺的能力,行为可能随上游调整。**Chat Completions`/ai/v1/chat/completions`)端点已移除**,请迁移到 Responses 或 Messages。
以下各表对照网关行为:✅ 转发上游;⚠️ 接受但忽略(静默丢弃,不影响请求);❌ 拒绝(400,不发上游)。
### Responses 字段
请求体除下列例外**原样直通上游**(未列字段一并转发,效果由上游决定):
| 字段 | 支持 | 说明 |
| --- | --- | --- |
| `model``input` | ✅ | `input` 接受 string 或 item 数组 |
| `stream` | ✅ | SSE,上游原生事件流(含 gpt-oss 推理增量);与服务端工具互斥 |
| `reasoning.effort` | ✅ | 直达上游不限档位,见下「推理力度」 |
| `store` | ⚠️ | 网关无状态,强制改写为 `false` |
| `previous_response_id``conversation``background` | ❌ | 网关不保存历史,请求需自带完整上下文 |
| `web_search` / `x_search` / `function` 之外的工具类型 | ❌ | 如 `file_search``code_interpreter``mcp` |
**服务端工具**`tools` 支持 `{"type":"web_search"}``{"type":"x_search"}`xAI Grok 官方 Agent Tools 格式,可与 `function` 混用),响应保留 `web_search_call` 输出项与引用;**仅非流式**。响应 `usage.input_tokens_details.cached_tokens` 透传缓存命中量。
### Anthropic Messages 字段
网关把 Messages 请求转换为 Responses 请求送上游,响应(含流式事件序列)转回 Anthropic 形态:
| 字段 | 支持 | 说明 |
| --- | --- | --- |
| `model``max_tokens``messages` | ✅ | `max_tokens` 必填;content 块支持 `text` / `image` / `tool_use` / `tool_result`,其余块类型 400 |
| `system` | ✅ | string 或块数组,映射为 `instructions` |
| `temperature``top_p` | ✅ | |
| `stream` | ✅ | SSE,标准 Anthropic 事件序列(`message_start``content_block_*``message_delta``message_stop` |
| `tools``tool_choice` | ✅ | 映射为 Responses `function` 工具;`tool_choice` 支持 `auto` / `any` / `none` / `tool` |
| `output_config.effort` | ✅ | 直达上游不限档位,见下「推理力度」 |
| `stop_sequences``top_k``metadata``thinking``service_tier` | ⚠️ | Responses 面无对应物,静默忽略 |
模型的思考输出(reasoning)不转为 `thinking` 块,静默丢弃;`usage.cache_read_input_tokens` 透传缓存命中量。
### Embeddings 字段
| 字段 | 支持 | 说明 |
| --- | --- | --- |
| `model``input` | ✅ | `input` 接受 string 或数组 |
| `dimensions` | ✅ | |
| `encoding_format``user` | ⚠️ | 输出恒为 float 数组 |
### 推理力度(effort
两个对话协议各以原生字段控制推理深度,取值直达上游不做档位校验:
| 协议 | 字段 |
| --- | --- |
| Responses | `reasoning.effort` |
| Anthropic Messages | `output_config.effort`(转小写后透传) |
是否支持、可用档位与实际效果由模型决定(2026-07 兼容面实测):
| 模型 | 支持档位 | 实测行为 |
| --- | --- | --- |
| `xai.grok-4.3` | `none` / `minimal` / `low` / `medium` / `high` | 全部生效:`none` 完全关闭推理,默认 `low``high` 推理量最大 |
| `openai.gpt-oss-*` | `minimal` / `low` / `medium` / `high` | `none` 被上游 400(Harmony 格式不支持);思考计入 completion tokens |
| `xai.grok-3-mini(-fast)` | 名义 `low` / `high` | 各档均被接受,但 `none` 并不关闭推理(仍产生思考) |
| `meta.llama-*` | 接受任意档 | 非推理模型,参数无实际效果 |
| `xai.grok-4.20-multi-agent*` | `low` / `medium` / `high` / `xhigh` | effort 控制并行 agent 数(4 / 16)而非推理深度 |
| 其余 grok`grok-3``grok-4``grok-4-fast-*``grok-4-1-fast-*``grok-code-fast-1``grok-4.20-*` 单/双型号全系) | 不支持 | 携带即被上游 400(`does not support parameter reasoningEffort` |
不携带该字段时网关不下发,行为由模型默认档位决定。
### 通用行为
- 未知模型 404;无可用渠道 503;单次请求最多尝试 3 个渠道(429/5xx/网络错误自动换渠道并计入熔断,模型级 400/404 换渠道不计熔断,其余 4xx 原样透传);流式建立后绑定渠道,中断不重试
- 调用计量(渠道、token 用量、缓存命中、时延、重试数)写入面板「调用日志」;密钥可按需开启限时内容抓取
## 环境变量
| 变量 | 必填 | 默认值 | 说明 |
@@ -88,6 +245,7 @@ CGO_ENABLED=0 go build -trimpath \
| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) |
| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) |
| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) |
| `GIN_MODE` | 否 | `release` | `debug` / `release`,影响日志与调试输出 |
## 开发
+40 -1
View File
@@ -9,12 +9,15 @@ import (
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
// 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata,
// 嵌入后 TZ 环境变量即可让 cron 按本地时区解释
_ "time/tzdata"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
// swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费)
@@ -35,6 +38,12 @@ import (
// @in header
// @name Authorization
// @description 登录接口返回的 JWT,格式: Bearer <token>
func init() {
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
}
func main() {
healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)")
flag.Parse()
@@ -126,6 +135,7 @@ func run() error {
defer aiGateway.Wait()
defer stopCleanup()
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
ociConfigs.SetTenantCleanupDeps(tasks, logEvents)
tasks.AttachAiGateway(aiGateway)
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
if err := tasks.Start(); err != nil {
@@ -137,5 +147,34 @@ func run() error {
console := service.NewConsoleService(ociConfigs)
proxies := service.NewProxyService(db, cipher)
defer proxies.Wait()
return api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway).Run(cfg.Addr)
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
return serveHTTP(cfg.Addr, router)
}
// serveHTTP 以显式超时与请求头上限启动 HTTP 服务,并在 SIGINT/SIGTERM 时
// 优雅关闭(10 秒排空);防慢连接(Slowloris)与超大头耗尽资源。
// WriteTimeout 置 0:AI 网关流式响应无固定上界,WebSocket 为 hijack 连接
// 本就不受 net/http 超时管控;读侧超时已覆盖慢请求风险。
func serveHTTP(addr string, handler http.Handler) error {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Println("[info] shutting down http server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
+3 -5
View File
@@ -1,9 +1,10 @@
services:
oci-portal:
image: ghcr.io/wangdefaa/oci-portal:latest
container_name: oci-portal
restart: unless-stopped
ports:
- "18888:8080"
- "127.0.0.1:18888:8080"
environment:
DB_PATH: /data/oci-portal.db
DATA_KEY: ${DATA_KEY:?DATA_KEY is required}
@@ -11,7 +12,4 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
TZ: Asia/Shanghai
volumes:
- oci-portal-data:/data
volumes:
oci-portal-data:
- ./data:/data
+571 -37
View File
@@ -15,34 +15,6 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/ai/v1/chat/completions": {
"post": {
"tags": [
"AI 网关"
],
"summary": "OpenAI 兼容对话补全",
"parameters": [
{
"description": "OpenAI chat/completions 请求体(支持 stream)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"200": {
"description": "OpenAI 兼容响应(流式为 SSE)",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/ai/v1/embeddings": {
"post": {
"tags": [
@@ -79,7 +51,7 @@ const docTemplate = `{
"summary": "Anthropic Messages 兼容端点",
"parameters": [
{
"description": "Anthropic messages 请求体(支持 stream)",
"description": "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)",
"name": "body",
"in": "body",
"required": true,
@@ -124,7 +96,7 @@ const docTemplate = `{
"summary": "OpenAI Responses 兼容端点",
"parameters": [
{
"description": "OpenAI responses 请求体(支持 stream)",
"description": "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)",
"name": "body",
"in": "body",
"required": true,
@@ -166,6 +138,86 @@ const docTemplate = `{
}
}
},
"/api/v1/ai-blacklist": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "模型黑名单列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "添加模型黑名单",
"parameters": [
{
"description": "请求体:{name: 模型名}",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/ai-blacklist/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "移除模型黑名单(重新同步后模型恢复入池)",
"parameters": [
{
"type": "integer",
"description": "黑名单条目 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/ai-channels": {
"get": {
"security": [
@@ -871,6 +923,30 @@ const docTemplate = `{
}
}
},
"/api/v1/auth/revoke-sessions": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "撤销全部会话",
"responses": {
"200": {
"description": "新 token 与 expiresAt",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/auth/totp": {
"get": {
"security": [
@@ -1078,6 +1154,124 @@ const docTemplate = `{
}
}
},
"/api/v1/log-events/alert-rules": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "告警规则列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "创建告警规则",
"parameters": [
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.alertRuleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/log-events/alert-rules/{ruleId}": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "更新告警规则",
"parameters": [
{
"type": "integer",
"description": "规则 ID",
"name": "ruleId",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.alertRuleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "删除告警规则",
"parameters": [
{
"type": "integer",
"description": "规则 ID",
"name": "ruleId",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs": {
"get": {
"security": [
@@ -1205,6 +1399,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。",
"tags": [
"租户配置"
],
@@ -1235,7 +1430,7 @@ const docTemplate = `{
"tags": [
"租户 IAM"
],
"summary": "实时查询租户 OCI 审计事件:hours 首查",
"summary": "批式懒加载查询租户 OCI 审计事件",
"parameters": [
{
"type": "integer",
@@ -1243,6 +1438,18 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "续查游标(上次响应原样带回)",
"name": "cursor",
"in": "query"
},
{
"type": "integer",
"description": "单批目标条数,缺省 100,上限 200",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -1656,6 +1863,37 @@ const docTemplate = `{
}
}
},
"/api/v1/oci-configs/{id}/domains": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"租户 IAM"
],
"summary": "租户身份域列表",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/oci-configs/{id}/identity-providers": {
"get": {
"security": [
@@ -1674,6 +1912,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -1704,6 +1948,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体",
"name": "body",
@@ -1744,6 +1994,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "idpId",
@@ -1778,6 +2034,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "idpId",
@@ -1824,6 +2086,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -1854,6 +2122,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3015,6 +3289,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3045,6 +3325,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3084,6 +3370,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3116,6 +3408,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "policyId",
@@ -3231,6 +3529,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3478,6 +3782,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3518,6 +3828,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "ruleId",
@@ -3551,6 +3867,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3835,6 +4157,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3865,6 +4193,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体",
"name": "body",
@@ -3905,6 +4239,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -3941,6 +4281,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -3986,6 +4332,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4058,6 +4410,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4096,6 +4454,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4749,6 +5113,95 @@ const docTemplate = `{
}
}
},
"/api/v1/settings/notify-channels": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "返回全部通知渠道的脱敏视图",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/settings/notify-channels/{type}": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "保存单渠道配置并返回全渠道最新视图",
"parameters": [
{
"type": "string",
"description": "渠道类型(webhook/ntfy/bark/smtp)",
"name": "type",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.updateNotifyChannelRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/settings/notify-channels/{type}/test": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "向指定渠道发送测试消息",
"parameters": [
{
"type": "string",
"description": "渠道类型(webhook/ntfy/bark/smtp)",
"name": "type",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/settings/notify-events": {
"get": {
"security": [
@@ -5337,7 +5790,7 @@ const docTemplate = `{
"tags": [
"任务与日志回传"
],
"summary": "立即执行任务",
"summary": "立即执行任务(异步触发,结果经任务日志轮询获取)",
"parameters": [
{
"type": "integer",
@@ -5348,8 +5801,15 @@ const docTemplate = `{
}
],
"responses": {
"200": {
"description": "OK",
"202": {
"description": "Accepted",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "任务正在执行中",
"schema": {
"type": "object",
"additionalProperties": true
@@ -5403,6 +5863,38 @@ const docTemplate = `{
}
},
"definitions": {
"internal_api.alertRuleRequest": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"eventTypes": {
"type": "string"
},
"name": {
"type": "string"
},
"ociConfigId": {
"type": "integer"
},
"resourceMatch": {
"type": "string"
},
"sourceIpMode": {
"type": "string"
},
"sourceIps": {
"type": "string"
},
"threshold": {
"type": "integer"
},
"windowMinutes": {
"type": "integer"
}
}
},
"internal_api.attachBootVolumeRequest": {
"type": "object",
"required": [
@@ -5817,14 +6309,18 @@ const docTemplate = `{
],
"properties": {
"password": {
"type": "string"
"type": "string",
"maxLength": 128
},
"totpCode": {
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
"type": "string"
"type": "string",
"maxLength": 8
},
"username": {
"type": "string"
"description": "字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)",
"type": "string",
"maxLength": 64
}
}
},
@@ -5891,6 +6387,44 @@ const docTemplate = `{
}
}
},
"internal_api.updateNotifyChannelRequest": {
"type": "object",
"properties": {
"bodyTemplate": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"from": {
"type": "string"
},
"host": {
"type": "string"
},
"port": {
"type": "integer"
},
"secret": {
"type": "string"
},
"server": {
"type": "string"
},
"to": {
"type": "string"
},
"topic": {
"type": "string"
},
"url": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"internal_api.updateSecurityListRequest": {
"type": "object",
"properties": {
+571 -37
View File
@@ -8,34 +8,6 @@
},
"basePath": "/",
"paths": {
"/ai/v1/chat/completions": {
"post": {
"tags": [
"AI 网关"
],
"summary": "OpenAI 兼容对话补全",
"parameters": [
{
"description": "OpenAI chat/completions 请求体(支持 stream)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"200": {
"description": "OpenAI 兼容响应(流式为 SSE)",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/ai/v1/embeddings": {
"post": {
"tags": [
@@ -72,7 +44,7 @@
"summary": "Anthropic Messages 兼容端点",
"parameters": [
{
"description": "Anthropic messages 请求体(支持 stream)",
"description": "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)",
"name": "body",
"in": "body",
"required": true,
@@ -117,7 +89,7 @@
"summary": "OpenAI Responses 兼容端点",
"parameters": [
{
"description": "OpenAI responses 请求体(支持 stream)",
"description": "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)",
"name": "body",
"in": "body",
"required": true,
@@ -159,6 +131,86 @@
}
}
},
"/api/v1/ai-blacklist": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "模型黑名单列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "添加模型黑名单",
"parameters": [
{
"description": "请求体:{name: 模型名}",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/ai-blacklist/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"AI 管理"
],
"summary": "移除模型黑名单(重新同步后模型恢复入池)",
"parameters": [
{
"type": "integer",
"description": "黑名单条目 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/ai-channels": {
"get": {
"security": [
@@ -864,6 +916,30 @@
}
}
},
"/api/v1/auth/revoke-sessions": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "撤销全部会话",
"responses": {
"200": {
"description": "新 token 与 expiresAt",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/auth/totp": {
"get": {
"security": [
@@ -1071,6 +1147,124 @@
}
}
},
"/api/v1/log-events/alert-rules": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "告警规则列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "创建告警规则",
"parameters": [
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.alertRuleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/log-events/alert-rules/{ruleId}": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "更新告警规则",
"parameters": [
{
"type": "integer",
"description": "规则 ID",
"name": "ruleId",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.alertRuleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"任务与日志回传"
],
"summary": "删除告警规则",
"parameters": [
{
"type": "integer",
"description": "规则 ID",
"name": "ruleId",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs": {
"get": {
"security": [
@@ -1198,6 +1392,7 @@
"BearerAuth": []
}
],
"description": "删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。",
"tags": [
"租户配置"
],
@@ -1228,7 +1423,7 @@
"tags": [
"租户 IAM"
],
"summary": "实时查询租户 OCI 审计事件:hours 首查",
"summary": "批式懒加载查询租户 OCI 审计事件",
"parameters": [
{
"type": "integer",
@@ -1236,6 +1431,18 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "续查游标(上次响应原样带回)",
"name": "cursor",
"in": "query"
},
{
"type": "integer",
"description": "单批目标条数,缺省 100,上限 200",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -1649,6 +1856,37 @@
}
}
},
"/api/v1/oci-configs/{id}/domains": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"租户 IAM"
],
"summary": "租户身份域列表",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/oci-configs/{id}/identity-providers": {
"get": {
"security": [
@@ -1667,6 +1905,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -1697,6 +1941,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体",
"name": "body",
@@ -1737,6 +1987,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "idpId",
@@ -1771,6 +2027,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "idpId",
@@ -1817,6 +2079,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -1847,6 +2115,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3008,6 +3282,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3038,6 +3318,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3077,6 +3363,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3109,6 +3401,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "policyId",
@@ -3224,6 +3522,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3471,6 +3775,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体(见接口说明)",
"name": "body",
@@ -3511,6 +3821,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "ruleId",
@@ -3544,6 +3860,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3828,6 +4150,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
}
],
"responses": {
@@ -3858,6 +4186,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"description": "请求体",
"name": "body",
@@ -3898,6 +4232,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -3934,6 +4274,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -3979,6 +4325,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4051,6 +4403,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4089,6 +4447,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "身份域 OCID(缺省 Default 域)",
"name": "domainId",
"in": "query"
},
{
"type": "string",
"description": "userId",
@@ -4742,6 +5106,95 @@
}
}
},
"/api/v1/settings/notify-channels": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "返回全部通知渠道的脱敏视图",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/settings/notify-channels/{type}": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "保存单渠道配置并返回全渠道最新视图",
"parameters": [
{
"type": "string",
"description": "渠道类型(webhook/ntfy/bark/smtp)",
"name": "type",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.updateNotifyChannelRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/v1/settings/notify-channels/{type}/test": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"设置"
],
"summary": "向指定渠道发送测试消息",
"parameters": [
{
"type": "string",
"description": "渠道类型(webhook/ntfy/bark/smtp)",
"name": "type",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/settings/notify-events": {
"get": {
"security": [
@@ -5330,7 +5783,7 @@
"tags": [
"任务与日志回传"
],
"summary": "立即执行任务",
"summary": "立即执行任务(异步触发,结果经任务日志轮询获取)",
"parameters": [
{
"type": "integer",
@@ -5341,8 +5794,15 @@
}
],
"responses": {
"200": {
"description": "OK",
"202": {
"description": "Accepted",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "任务正在执行中",
"schema": {
"type": "object",
"additionalProperties": true
@@ -5396,6 +5856,38 @@
}
},
"definitions": {
"internal_api.alertRuleRequest": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"eventTypes": {
"type": "string"
},
"name": {
"type": "string"
},
"ociConfigId": {
"type": "integer"
},
"resourceMatch": {
"type": "string"
},
"sourceIpMode": {
"type": "string"
},
"sourceIps": {
"type": "string"
},
"threshold": {
"type": "integer"
},
"windowMinutes": {
"type": "integer"
}
}
},
"internal_api.attachBootVolumeRequest": {
"type": "object",
"required": [
@@ -5810,14 +6302,18 @@
],
"properties": {
"password": {
"type": "string"
"type": "string",
"maxLength": 128
},
"totpCode": {
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
"type": "string"
"type": "string",
"maxLength": 8
},
"username": {
"type": "string"
"description": "字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)",
"type": "string",
"maxLength": 64
}
}
},
@@ -5884,6 +6380,44 @@
}
}
},
"internal_api.updateNotifyChannelRequest": {
"type": "object",
"properties": {
"bodyTemplate": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"from": {
"type": "string"
},
"host": {
"type": "string"
},
"port": {
"type": "integer"
},
"secret": {
"type": "string"
},
"server": {
"type": "string"
},
"to": {
"type": "string"
},
"topic": {
"type": "string"
},
"url": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"internal_api.updateSecurityListRequest": {
"type": "object",
"properties": {
+361 -24
View File
@@ -1,5 +1,26 @@
basePath: /
definitions:
internal_api.alertRuleRequest:
properties:
enabled:
type: boolean
eventTypes:
type: string
name:
type: string
ociConfigId:
type: integer
resourceMatch:
type: string
sourceIpMode:
type: string
sourceIps:
type: string
threshold:
type: integer
windowMinutes:
type: integer
type: object
internal_api.attachBootVolumeRequest:
properties:
bootVolumeId:
@@ -277,11 +298,15 @@ definitions:
internal_api.loginRequest:
properties:
password:
maxLength: 128
type: string
totpCode:
description: 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
maxLength: 8
type: string
username:
description: 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
maxLength: 64
type: string
required:
- password
@@ -328,6 +353,31 @@ definitions:
description: 更换 shape,运行中实例会自动重启
type: string
type: object
internal_api.updateNotifyChannelRequest:
properties:
bodyTemplate:
type: string
enabled:
type: boolean
from:
type: string
host:
type: string
port:
type: integer
secret:
type: string
server:
type: string
to:
type: string
topic:
type: string
url:
type: string
username:
type: string
type: object
internal_api.updateSecurityListRequest:
properties:
displayName:
@@ -549,24 +599,6 @@ info:
title: OCI Portal API
version: 0.0.1
paths:
/ai/v1/chat/completions:
post:
parameters:
- description: OpenAI chat/completions 请求体(支持 stream)
in: body
name: body
required: true
schema:
type: object
responses:
"200":
description: OpenAI 兼容响应(流式为 SSE)
schema:
additionalProperties: true
type: object
summary: OpenAI 兼容对话补全
tags:
- AI 网关
/ai/v1/embeddings:
post:
parameters:
@@ -588,7 +620,7 @@ paths:
/ai/v1/messages:
post:
parameters:
- description: Anthropic messages 请求体(支持 stream)
- description: Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)
in: body
name: body
required: true
@@ -617,7 +649,7 @@ paths:
/ai/v1/responses:
post:
parameters:
- description: OpenAI responses 请求体(支持 stream)
- description: OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)
in: body
name: body
required: true
@@ -645,6 +677,54 @@ paths:
summary: 返回构建与运行环境信息,「设置 · 关于」页展示
tags:
- 设置
/api/v1/ai-blacklist:
get:
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 模型黑名单列表
tags:
- AI 管理
post:
parameters:
- description: '请求体:{name: 模型名}'
in: body
name: body
required: true
schema:
type: object
responses:
"201":
description: Created
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 添加模型黑名单
tags:
- AI 管理
/api/v1/ai-blacklist/{id}:
delete:
parameters:
- description: 黑名单条目 ID
in: path
name: id
required: true
type: integer
responses:
"204":
description: 无内容
security:
- BearerAuth: []
summary: 移除模型黑名单(重新同步后模型恢复入池)
tags:
- AI 管理
/api/v1/ai-channels:
get:
responses:
@@ -1078,6 +1158,20 @@ paths:
summary: 密码登录开关
tags:
- 认证
/api/v1/auth/revoke-sessions:
post:
responses:
"200":
description: 新 token 与 expiresAt
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: 撤销全部会话
tags:
- 认证
/api/v1/auth/totp:
get:
responses:
@@ -1203,6 +1297,78 @@ paths:
summary: 回传日志事件列表
tags:
- 任务与日志回传
/api/v1/log-events/alert-rules:
get:
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 告警规则列表
tags:
- 任务与日志回传
post:
parameters:
- description: 请求体
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.alertRuleRequest'
responses:
"201":
description: Created
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 创建告警规则
tags:
- 任务与日志回传
/api/v1/log-events/alert-rules/{ruleId}:
delete:
parameters:
- description: 规则 ID
in: path
name: ruleId
required: true
type: integer
responses:
"204":
description: 无内容
security:
- BearerAuth: []
summary: 删除告警规则
tags:
- 任务与日志回传
put:
parameters:
- description: 规则 ID
in: path
name: ruleId
required: true
type: integer
- description: 请求体
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.alertRuleRequest'
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 更新告警规则
tags:
- 任务与日志回传
/api/v1/oci-configs:
get:
responses:
@@ -1237,6 +1403,7 @@ paths:
- 租户配置
/api/v1/oci-configs/{id}:
delete:
description: 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
parameters:
- description: 配置 ID
in: path
@@ -1301,6 +1468,14 @@ paths:
name: id
required: true
type: integer
- description: 续查游标(上次响应原样带回)
in: query
name: cursor
type: string
- description: 单批目标条数,缺省 100,上限 200
in: query
name: limit
type: integer
responses:
"200":
description: OK
@@ -1309,7 +1484,7 @@ paths:
type: object
security:
- BearerAuth: []
summary: 实时查询租户 OCI 审计事件:hours 首查
summary: 批式懒加载查询租户 OCI 审计事件
tags:
- 租户 IAM
/api/v1/oci-configs/{id}/audit-events/detail:
@@ -1560,6 +1735,25 @@ paths:
summary: 配置成本快照
tags:
- 成本
/api/v1/oci-configs/{id}/domains:
get:
parameters:
- description: 配置 ID
in: path
name: id
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 租户身份域列表
tags:
- 租户 IAM
/api/v1/oci-configs/{id}/identity-providers:
get:
parameters:
@@ -1568,6 +1762,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -1586,6 +1784,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: 请求体
in: body
name: body
@@ -1611,6 +1813,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: idpId
in: path
name: idpId
@@ -1632,6 +1838,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: idpId
in: path
name: idpId
@@ -1662,6 +1872,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -1680,6 +1894,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: 请求体(见接口说明)
in: body
name: body
@@ -2412,6 +2630,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -2430,6 +2652,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: 请求体(见接口说明)
in: body
name: body
@@ -2455,6 +2681,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -2474,6 +2704,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: policyId
in: path
name: policyId
@@ -2547,6 +2781,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -2701,6 +2939,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: 请求体(见接口说明)
in: body
name: body
@@ -2726,6 +2968,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: ruleId
in: path
name: ruleId
@@ -2747,6 +2993,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -2925,6 +3175,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
responses:
"200":
description: OK
@@ -2943,6 +3197,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: 请求体
in: body
name: body
@@ -2968,6 +3226,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: userId
in: path
name: userId
@@ -2988,6 +3250,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: userId
in: path
name: userId
@@ -3011,6 +3277,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: userId
in: path
name: userId
@@ -3065,6 +3335,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: userId
in: path
name: userId
@@ -3089,6 +3363,10 @@ paths:
name: id
required: true
type: integer
- description: 身份域 OCID(缺省 Default 域)
in: query
name: domainId
type: string
- description: userId
in: path
name: userId
@@ -3498,6 +3776,60 @@ paths:
summary: 返回本地维护的完整区域表
tags:
- 租户配置
/api/v1/settings/notify-channels:
get:
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 返回全部通知渠道的脱敏视图
tags:
- 设置
/api/v1/settings/notify-channels/{type}:
put:
parameters:
- description: 渠道类型(webhook/ntfy/bark/smtp)
in: path
name: type
required: true
type: string
- description: 请求体
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.updateNotifyChannelRequest'
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 保存单渠道配置并返回全渠道最新视图
tags:
- 设置
/api/v1/settings/notify-channels/{type}/test:
post:
parameters:
- description: 渠道类型(webhook/ntfy/bark/smtp)
in: path
name: type
required: true
type: string
responses:
"204":
description: 无内容
security:
- BearerAuth: []
summary: 向指定渠道发送测试消息
tags:
- 设置
/api/v1/settings/notify-events:
get:
responses:
@@ -3856,14 +4188,19 @@ paths:
required: true
type: integer
responses:
"200":
description: OK
"202":
description: Accepted
schema:
additionalProperties: true
type: object
"409":
description: 任务正在执行中
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 立即执行任务
summary: 立即执行任务(异步触发,结果经任务日志轮询获取)
tags:
- 任务与日志回传
/api/v1/webhooks/oci-logs/{secret}:
+7
View File
@@ -20,6 +20,13 @@ type MessagesRequest struct {
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
// OutputConfig 是 Anthropic 官方输出控制(effort 等);网关映射 effort 到 OCI reasoningEffort。
OutputConfig *AnthOutputConfig `json:"output_config,omitempty"`
}
// AnthOutputConfig 是 Anthropic Messages 顶层 output_config 对象。
type AnthOutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// SystemText 把顶层 system(string 或块数组)拍平为文本。
+2 -209
View File
@@ -1,35 +1,11 @@
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Chat Completions(兼作网关内部
// 中间表示 IR,因 OCI GENERIC 格式即其镜像)与 Anthropic Messages。零内部依赖,
// oci 层与 service 层共同引用。
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Anthropic Messages /
// Embeddings 与通用记账、错误、模型列表结构。上游统一为 OCI OpenAI 兼容面。
package aiwire
import (
"encoding/json"
"strings"
)
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;TopK 为 OCI/Anthropic 扩展字段。
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
Stop StringList `json:"stop,omitempty"`
Seed *int `json:"seed,omitempty"`
N *int `json:"n,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
type StringList []string
@@ -50,151 +26,6 @@ func (s *StringList) UnmarshalJSON(b []byte) error {
return nil
}
// StreamOptions 控制流式末尾是否附带 usage 事件。
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// ChatMessage 是请求与非流式响应共用的消息结构。
type ChatMessage struct {
Role string `json:"role"`
Content Content `json:"content"`
Name string `json:"name,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// Content 兼容 string 与多模态块数组两种线格式;一期仅透传文本。
type Content struct {
// Text 在原始值为字符串时填充
Text string
// Parts 在原始值为数组时填充
Parts []ContentPart
// isArray 记录原始形态,序列化时保形
isArray bool
}
// ContentPart 是块数组中的一段;text 与 image_url 被网关处理,其余类型拒绝。
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址,与 OCI ImageUrl 同构。
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
func (c *Content) UnmarshalJSON(b []byte) error {
trimmed := strings.TrimSpace(string(b))
if trimmed == "null" {
return nil
}
if len(trimmed) > 0 && trimmed[0] == '[' {
c.isArray = true
return json.Unmarshal(b, &c.Parts)
}
return json.Unmarshal(b, &c.Text)
}
func (c Content) MarshalJSON() ([]byte, error) {
if c.isArray {
return json.Marshal(c.Parts)
}
return json.Marshal(c.Text)
}
// NewTextContent 构造字符串形态的内容。
func NewTextContent(text string) Content {
return Content{Text: text}
}
// NewPartsContent 构造块数组形态的内容(多模态消息用)。
func NewPartsContent(parts []ContentPart) Content {
return Content{Parts: parts, isArray: true}
}
// JoinText 返回全部文本内容(数组形态拼接 text 块)。
func (c Content) JoinText() string {
if !c.isArray {
return c.Text
}
var sb strings.Builder
for _, p := range c.Parts {
if p.Type == "" || p.Type == "text" {
sb.WriteString(p.Text)
}
}
return sb.String()
}
// HasUnsupported 报告是否含网关无法承接的内容块(text / image_url 之外的类型)。
func (c Content) HasUnsupported() bool {
for _, p := range c.Parts {
switch p.Type {
case "", "text":
case "image_url":
if p.ImageURL == nil || p.ImageURL.URL == "" {
return true
}
default:
return true
}
}
return false
}
// Tool 是 function 工具定义。
type Tool struct {
Type string `json:"type"`
Function FunctionDef `json:"function"`
}
// FunctionDef 描述工具的名称与 JSON Schema 参数。
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// ToolCall 是助手消息中的工具调用。
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
// FunctionCall 携带调用名与 JSON 编码的实参。
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ResponseFormat 对应 response_format(json_object / json_schema)。
type ResponseFormat struct {
Type string `json:"type"`
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
}
// ChatResponse 是非流式响应体。
type ChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// Choice 是一条候选回复。
type Choice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Usage 是 token 用量。
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
@@ -217,44 +48,6 @@ func (u *Usage) CachedTokens() int {
return u.PromptTokensDetails.CachedTokens
}
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
type ChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// ChunkChoice 是流式事件中的增量候选。
type ChunkChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// Delta 是流式增量内容;Content 为纯文本片段。
type Delta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
}
// ToolCallDelta 是工具调用的流式片段;Index 标识聚合目标。
type ToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function FunctionCallDelta `json:"function"`
}
// FunctionCallDelta 是函数名 / 实参的增量片段。
type FunctionCallDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
// Model 是 /v1/models 列表项。
type Model struct {
ID string `json:"id"`
-33
View File
@@ -156,39 +156,6 @@ type RespReasoning struct {
Effort string `json:"effort,omitempty"`
}
// Response 是 /responses 响应对象。
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Model string `json:"model"`
Output []RespOutItem `json:"output"`
Usage *RespUsage `json:"usage,omitempty"`
Error *RespError `json:"error"`
IncompleteDetails *RespIncomplete `json:"incomplete_details"`
Store bool `json:"store"`
}
// RespOutItem 是 output 数组项:message 或 function_call。
type RespOutItem struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status,omitempty"`
Role string `json:"role,omitempty"`
Content []RespOutPart `json:"content,omitempty"`
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
// RespOutPart 是 message item 的内容部件(output_text)。
type RespOutPart struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []any `json:"annotations"`
}
// RespUsage 是 Responses 口径的用量(input/output 命名)。
type RespUsage struct {
InputTokens int `json:"input_tokens"`
+68 -8
View File
@@ -40,15 +40,16 @@ func (h *aiAdminHandler) listKeys(c *gin.Context) {
// @Router /api/v1/ai-keys [post]
func (h *aiAdminHandler) createKey(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Value string `json:"value"`
Group string `json:"group"`
Name string `json:"name" binding:"required"`
Value string `json:"value"`
Group string `json:"group"`
Models []string `json:"models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group)
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group, req.Models)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -69,15 +70,16 @@ func (h *aiAdminHandler) updateKey(c *gin.Context) {
return
}
var req struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Group *string `json:"group"`
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Group *string `json:"group"`
Models *[]string `json:"models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group); err != nil {
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group, req.Models); err != nil {
respondError(c, err)
return
}
@@ -285,6 +287,64 @@ func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": list.Data})
}
// ---- 模型黑名单 ----
// @Summary 模型黑名单列表
// @Tags AI 管理
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/ai-blacklist [get]
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
items, err := h.gw.Blacklist(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// addBlacklist 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤。
//
// @Summary 添加模型黑名单
// @Tags AI 管理
// @Param body body object true "请求体:{name: 模型名}"
// @Success 201 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/ai-blacklist [post]
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item, err := h.gw.AddBlacklist(c.Request.Context(), req.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"item": item})
}
// @Summary 移除模型黑名单(重新同步后模型恢复入池)
// @Tags AI 管理
// @Param id path int true "黑名单条目 ID"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/ai-blacklist/{id} [delete]
func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
id, ok := aiPathID(c)
if !ok {
return
}
if err := h.gw.RemoveBlacklist(c.Request.Context(), id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary AI 调用日志列表
// @Tags AI 管理
// @Success 200 {object} map[string]any
+151 -179
View File
@@ -1,13 +1,15 @@
package api
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
@@ -86,6 +88,25 @@ func keyGroup(c *gin.Context) string {
return ""
}
// keyModels 取当前请求密钥的模型白名单(空 = 不限模型)。
func keyModels(c *gin.Context) []string {
if key, ok := c.Get(aiKeyCtx); ok {
return key.(*model.AiKey).Models
}
return nil
}
// checkKeyModel 校验请求模型是否在密钥白名单内;拒绝时按端点协议返回
// 404 model_not_found(与未知模型同口径,不泄露密钥配置细节)并返回 false。
func checkKeyModel(c *gin.Context, modelName string) bool {
allowed := keyModels(c)
if len(allowed) == 0 || slices.Contains(allowed, modelName) {
return true
}
aiError(c, http.StatusNotFound, "model_not_found", "模型不存在或无权访问: "+modelName)
return false
}
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
entry := model.AiCallLog{
@@ -102,21 +123,6 @@ func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string,
}
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
func validateIR(ir aiwire.ChatRequest) error {
if strings.TrimSpace(ir.Model) == "" {
return fmt.Errorf("model 不能为空")
}
if len(ir.Messages) == 0 {
return fmt.Errorf("messages 不能为空")
}
for _, m := range ir.Messages {
if m.Content.HasUnsupported() {
return service.ErrAiUnsupportedBlock
}
}
return nil
}
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
@@ -147,47 +153,6 @@ func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, req
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
}
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点。
//
// @Summary OpenAI 兼容对话补全
// @Tags AI 网关
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
// @Router /ai/v1/chat/completions [post]
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
var ir aiwire.ChatRequest
if err := c.ShouldBindJSON(&ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if ir.Stream {
h.streamOpenAI(c, ir)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "openai", ir.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
return
}
resp.ID = aiRandID("chatcmpl-")
if resp.Created == 0 {
resp.Created = time.Now().Unix()
}
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", ir.Model, false, ir, resp)
c.JSON(http.StatusOK, resp)
}
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
if u == nil {
return
@@ -217,6 +182,9 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
return
}
if !checkKeyModel(c, req.Model) {
return
}
start := time.Now()
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
@@ -235,44 +203,6 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// streamOpenAI 以 OpenAI SSE 直通流式响应,末尾发 [DONE]。
func (h *aiGatewayHandler) streamOpenAI(c *gin.Context, ir aiwire.ChatRequest) {
start := time.Now()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "openai", ir.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
return
}
defer stream.Close()
sseHeaders(c)
id, created := aiRandID("chatcmpl-"), time.Now().Unix()
var usage *aiwire.Usage
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
entry.ErrMsg = err.Error()
}
break
}
chunk.ID, chunk.Created = id, created
if chunk.Usage != nil {
usage = chunk.Usage
}
writeSSEData(c, chunk)
}
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "openai", ir.Model, true, ir, nil)
}
func sseHeaders(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
@@ -280,22 +210,11 @@ func sseHeaders(c *gin.Context) {
c.Writer.Flush()
}
func writeSSEData(c *gin.Context, v any) {
b, err := json.Marshal(v)
if err != nil {
return
}
c.Writer.WriteString("data: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
c.Writer.Flush()
}
// messages 是 Anthropic /ai/v1/messages 端点。
//
// @Summary Anthropic Messages 兼容端点
// @Tags AI 网关
// @Param body body object true "Anthropic messages 请求体(支持 stream)"
// @Param body body object true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)"
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
// @Router /ai/v1/messages [post]
func (h *aiGatewayHandler) messages(c *gin.Context) {
@@ -308,69 +227,92 @@ func (h *aiGatewayHandler) messages(c *gin.Context) {
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
return
}
ir, err := service.AnthropicToIR(req)
if len(req.Messages) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
return
}
if !checkKeyModel(c, req.Model) {
return
}
body, err := service.AnthropicToResponsesBody(req)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamAnthropic(c, ir)
h.streamAnthropic(c, body, req)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
if err != nil {
aiError(c, http.StatusBadGateway, "api_error", err.Error())
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
h.maybeLogContent(c, callID, "anthropic", req.Model, false, req, out)
c.JSON(http.StatusOK, out)
}
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
// streamAnthropic 流式直通并桥接:上游 Responses SSE 事件经 AnthRespBridge
// 转为 Anthropic 事件序列逐块写出;usage 从 completed 事件记账。
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwire.MessagesRequest) {
start := time.Now()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
h.logFailure(c, entry, req)
return
}
defer stream.Close()
defer upstream.Close()
sseHeaders(c)
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
entry.ErrMsg = err.Error()
}
break
}
writeAnthEvents(c, st.Feed(chunk))
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
if err := forwardAnthSSE(c, upstream, bridge); err != nil {
entry.ErrMsg = err.Error()
}
writeAnthEvents(c, st.Finish())
writeAnthEvents(c, bridge.Finish())
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
usage := st.Usage()
usage := bridge.Usage()
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
entry.CachedTokens = usage.CacheReadInputTokens
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "anthropic", ir.Model, true, ir, nil)
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
}
// forwardAnthSSE 逐行读上游 SSE,把 data 行喂给桥并即时写出转换后的事件。
func forwardAnthSSE(c *gin.Context, upstream io.Reader, bridge *service.AnthRespBridge) error {
reader := bufio.NewReader(upstream)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
trimmed := bytes.TrimSpace(line)
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
writeAnthEvents(c, bridge.Feed(data))
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
@@ -398,6 +340,15 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
return
}
if allowed := keyModels(c); len(allowed) > 0 {
kept := make([]aiwire.Model, 0, len(list.Data))
for _, m := range list.Data {
if slices.Contains(allowed, m.ID) {
kept = append(kept, m)
}
}
list.Data = kept
}
c.JSON(http.StatusOK, list)
}
@@ -405,31 +356,45 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
//
// @Summary OpenAI Responses 兼容端点
// @Tags AI 网关
// @Param body body object true "OpenAI responses 请求体(支持 stream)"
// @Param body body object true "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
// @Router /ai/v1/responses [post]
func (h *aiGatewayHandler) responses(c *gin.Context) {
var req aiwire.RespRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
ir, err := service.ResponsesToIR(req)
raw, err := c.GetRawData()
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if err := validateIR(ir); err != nil {
var req aiwire.RespRequest
if err := json.Unmarshal(raw, &req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if !checkKeyModel(c, req.Model) {
return
}
h.responsesPassthrough(c, raw, req)
}
// responsesPassthrough 直通链路(唯一上游):原始 body 改写后直达
// OCI /actions/v1/responses,响应原样透传。
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
if err := service.RespPassthroughValidate(req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
body, err := service.RespPassthroughBody(raw)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamResponses(c, ir)
h.responsesPassthroughStream(c, body, req)
return
}
start := time.Now()
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
@@ -437,54 +402,61 @@ func (h *aiGatewayHandler) responses(c *gin.Context) {
return
}
entry.Status = http.StatusOK
fillUsage(&entry, resp.Usage)
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
c.JSON(http.StatusOK, out)
h.maybeLogContent(c, callID, "responses", req.Model, false, req, json.RawMessage(payload))
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
}
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流。
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
// 逐行扫描 completed 事件提取 usage 记账。
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
start := time.Now()
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, ir)
h.logFailure(c, entry, req)
return
}
defer stream.Close()
defer upstream.Close()
sseHeaders(c)
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
for {
chunk, err := stream.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
entry.ErrMsg = err.Error()
}
break
}
writeRespEvents(c, st.Feed(chunk))
usage, err := forwardSSE(c, upstream)
if err != nil {
entry.ErrMsg = err.Error()
}
writeRespEvents(c, st.Finish())
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, st.Usage())
fillUsage(&entry, usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", ir.Model, true, ir, nil)
h.maybeLogContent(c, callID, "responses", req.Model, true, req, nil)
}
func writeRespEvents(c *gin.Context, events []service.RespEvent) {
for _, ev := range events {
b, err := json.Marshal(ev.Data)
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
// 顺带从 data 行提取 response.completed 的 usage。
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, error) {
reader := bufio.NewReader(upstream)
var usage *aiwire.Usage
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
c.Writer.Write(line)
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
c.Writer.Flush()
} else if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
if u := service.RespStreamCompletedUsage(data); u != nil {
usage = u
}
}
}
if err != nil {
continue
c.Writer.Flush()
if errors.Is(err, io.EOF) {
return usage, nil
}
return usage, err
}
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
}
c.Writer.Flush()
}
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
t.Helper()
ch := &model.AiChannel{Name: "t-" + name, OciConfigID: 1, Region: "r-" + name, Enabled: true, Priority: 1, Weight: 1}
if err := db.Create(ch).Error; err != nil {
t.Fatalf("seed channel: %v", err)
}
cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1.." + name, Name: name, Vendor: "v", SyncedAt: time.Now()}
if err := db.Create(cache).Error; err != nil {
t.Fatalf("seed model cache: %v", err)
}
}
func TestAiGatewayKeyModelRestrict(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
// 受限密钥:白名单含脏输入,入库应规范化为 2 项(cohere + ghost)
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
`{"name":"limited","value":"limited-key-1234","models":[" cohere.command-a-03-2025 ","cohere.command-a-03-2025","","ghost-model"]}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建受限密钥 status = %d, body %s", w.Code, w.Body.String())
}
var created struct {
Item model.AiKey `json:"item"`
}
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("decode: %v", err)
}
if len(created.Item.Models) != 2 || created.Item.Models[0] != "cohere.command-a-03-2025" {
t.Fatalf("创建后 models 未规范化: %v", created.Item.Models)
}
// 不限密钥对照
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建不限密钥 status = %d, body %s", w.Code, w.Body.String())
}
seedGatewayModel(t, db, "cohere.command-a-03-2025")
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
deny := []string{"model_not_found", "无权访问"}
pass := []string{"未知模型"} // 穿过白名单闸门,由编排层以「未知模型」拒绝(池内无 ghost-model)
tests := []struct {
name string
key string
path string
body string
wantCode int
wantSub []string
}{
{"responses 流式同样拦截", "limited-key-1234", "/ai/v1/responses",
`{"model":"meta.llama-3.3-70b-instruct","stream":true,"input":"hi"}`, 404, deny},
{"responses 白名单内穿透", "limited-key-1234", "/ai/v1/responses",
`{"model":"ghost-model","input":"hi"}`, 404, pass},
{"embeddings 白名单外拦截", "limited-key-1234", "/ai/v1/embeddings",
`{"model":"meta.llama-3.3-70b-instruct","input":["hi"]}`, 404, deny},
{"messages 白名单外拦截为 Anthropic 体", "limited-key-1234", "/ai/v1/messages",
`{"model":"meta.llama-3.3-70b-instruct","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, 404,
[]string{`"type":"error"`, "model_not_found", "无权访问"}},
{"responses 白名单外拦截", "limited-key-1234", "/ai/v1/responses",
`{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny},
{"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses",
`{"model":"ghost-model","input":"hi"}`, 404, pass},
{"chat completions 端点已删除", "open-key-12345", "/ai/v1/chat/completions",
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, []string{"not found"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPost, tt.path, tt.key, tt.body)
if w.Code != tt.wantCode {
t.Fatalf("status = %d, want %d, body %s", w.Code, tt.wantCode, w.Body.String())
}
for _, sub := range tt.wantSub {
if !strings.Contains(w.Body.String(), sub) {
t.Errorf("body 缺少 %q: %s", sub, w.Body.String())
}
}
})
}
}
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
seedGatewayModel(t, db, "cohere.command-a-03-2025")
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
`{"name":"limited","value":"limited-key-1234","models":["cohere.command-a-03-2025"]}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建密钥 status = %d", w.Code)
}
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建密钥 status = %d", w.Code)
}
tests := []struct {
name string
key string
want []string
notWant []string
}{
{"受限密钥仅见交集", "limited-key-1234", []string{"cohere.command-a-03-2025"}, []string{"meta.llama-3.3-70b-instruct"}},
{"不限密钥全量可见", "open-key-12345", []string{"cohere.command-a-03-2025", "meta.llama-3.3-70b-instruct"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodGet, "/ai/v1/models", tt.key, "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", w.Code, w.Body.String())
}
for _, sub := range tt.want {
if !strings.Contains(w.Body.String(), sub) {
t.Errorf("应包含 %q: %s", sub, w.Body.String())
}
}
for _, sub := range tt.notWant {
if strings.Contains(w.Body.String(), sub) {
t.Errorf("不应包含 %q: %s", sub, w.Body.String())
}
}
})
}
}
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
r, auth, _, _ := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"k","value":"round-key-1234"}`)
if w.Code != http.StatusCreated {
t.Fatalf("创建 status = %d", w.Code)
}
var created struct {
Item model.AiKey `json:"item"`
}
_ = json.Unmarshal(w.Body.Bytes(), &created)
id := created.Item.ID
listModels := func() []string {
w := doRequest(t, r, http.MethodGet, "/api/v1/ai-keys", token, "")
var resp struct {
Items []model.AiKey `json:"items"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, k := range resp.Items {
if k.ID == id {
return k.Models
}
}
t.Fatalf("密钥 %d 不在列表中", id)
return nil
}
tests := []struct {
name string
body string
want []string
}{
{"设置白名单", `{"models":[" a-model ","a-model","b-model"]}`, []string{"a-model", "b-model"}},
{"不传 models 保持不变", `{"name":"k2"}`, []string{"a-model", "b-model"}},
{"空数组清空恢复不限", `{"models":[]}`, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPut, "/api/v1/ai-keys/"+strconv.Itoa(int(id)), token, tt.body)
if w.Code != http.StatusNoContent {
t.Fatalf("update status = %d, body %s", w.Code, w.Body.String())
}
got := listModels()
if len(got) != len(tt.want) {
t.Fatalf("models = %v, want %v", got, tt.want)
}
for i := range tt.want {
if got[i] != tt.want[i] {
t.Fatalf("models = %v, want %v", got, tt.want)
}
}
})
}
}
+5 -4
View File
@@ -18,10 +18,11 @@ type authHandler struct {
}
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
// 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
Username string `json:"username" binding:"required,max=64"`
Password string `json:"password" binding:"required,max=128"`
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
TotpCode string `json:"totpCode"`
TotpCode string `json:"totpCode" binding:"max=8"`
}
// login 校验用户名密码(与可选 TOTP)后签发 JWT。
@@ -64,7 +65,7 @@ func (h *authHandler) login(c *gin.Context) {
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, err)
return
}
h.recordLogin(c, req.Username, http.StatusOK, start)
+38 -10
View File
@@ -81,7 +81,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
h.respondFreshToken(c)
}
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
@@ -110,7 +110,34 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
h.respondFreshToken(c)
}
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录。
func (h *authxHandler) respondFreshToken(c *gin.Context) {
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
c.Status(http.StatusNoContent)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// revokeSessions 撤销全部会话(令牌版本递增),并为当前操作者重签新令牌。
//
// @Summary 撤销全部会话
// @Tags 认证
// @Success 200 {object} map[string]string "新 token 与 expiresAt"
// @Security BearerAuth
// @Router /api/v1/auth/revoke-sessions [post]
func (h *authxHandler) revokeSessions(c *gin.Context) {
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// ---- 登录凭据(JWT 组内) ----
@@ -149,7 +176,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
if errors.Is(err, service.ErrCredentialConfirm) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
@@ -162,7 +189,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
c.Set(usernameKey, finalName)
h.respondFreshToken(c)
}
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
@@ -191,7 +219,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
h.respondFreshToken(c)
}
// ---- OAuth ----
@@ -254,7 +282,7 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
if len(header) < 8 || header[:7] != "Bearer " {
return "", false
}
username, err := h.auth.ParseToken(header[7:])
username, err := h.auth.ParseToken(c.Request.Context(), header[7:])
if err != nil {
return "", false
}
@@ -282,9 +310,9 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
return
}
if token == "" {
// 绑定模式:回设置页安全 tab
c.Redirect(http.StatusFound, "/settings?oauth=bound")
if mode == "bind" {
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
return
}
// 登录模式:token 放 fragment,不进服务端日志与 Referer
@@ -340,7 +368,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
h.respondFreshToken(c)
}
// ---- OAuth provider 设置(JWT 组内) ----
+16 -8
View File
@@ -40,6 +40,7 @@ func boolOr(v *bool, def bool) bool {
// @Summary 身份提供商列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
@@ -48,7 +49,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
if !ok {
return
}
idps, err := h.svc.IdentityProviders(c.Request.Context(), id)
idps, err := h.svc.IdentityProviders(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -59,6 +60,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
// @Summary 创建身份提供商(SAML)
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createIdpRequest true "请求体"
// @Success 201 {object} map[string]any
// @Security BearerAuth
@@ -73,7 +75,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, c.Query("domainId"), oci.CreateIdpInput{
Name: req.Name,
Metadata: req.Metadata,
Description: req.Description,
@@ -97,6 +99,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
// @Summary 激活身份提供商
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param idpId path string true "idpId"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
@@ -114,7 +117,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled)
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId"), *req.Enabled)
if err != nil {
respondError(c, err)
return
@@ -125,6 +128,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
// @Summary 删除身份提供商
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param idpId path string true "idpId"
// @Success 204 "无内容"
// @Security BearerAuth
@@ -134,7 +138,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
if !ok {
return
}
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil {
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId")); err != nil {
respondError(c, err)
return
}
@@ -144,6 +148,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
// @Summary 下载 SAML 元数据
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
@@ -152,7 +157,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
if !ok {
return
}
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id)
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -164,6 +169,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
// @Summary 单点登录规则列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
@@ -172,7 +178,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
if !ok {
return
}
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id)
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -183,6 +189,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
// @Summary 创建 MFA 豁免规则
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any
// @Security BearerAuth
@@ -199,7 +206,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID)
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, c.Query("domainId"), req.IdentityProviderID)
if err != nil {
respondError(c, err)
return
@@ -210,6 +217,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
// @Summary 删除 MFA 豁免规则
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param ruleId path string true "ruleId"
// @Success 204 "无内容"
// @Security BearerAuth
@@ -219,7 +227,7 @@ func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
if !ok {
return
}
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil {
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Query("domainId"), c.Param("ruleId")); err != nil {
respondError(c, err)
return
}
+122
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
@@ -106,6 +107,127 @@ func (h *logEventHandler) list(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
// alertRuleRequest 是创建/更新告警规则的请求体。
type alertRuleRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"`
EventTypes string `json:"eventTypes"`
SourceIPs string `json:"sourceIps"`
SourceIPMode string `json:"sourceIpMode"`
ResourceMatch string `json:"resourceMatch"`
Threshold int `json:"threshold"`
WindowMinutes int `json:"windowMinutes"`
}
// toModel 转为规则模型(默认阈值 1)。
func (r alertRuleRequest) toModel() model.AlertRule {
if r.Threshold == 0 {
r.Threshold = 1
}
return model.AlertRule{
Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID,
EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode,
ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes,
}
}
// listAlertRules 返回全部告警规则。
//
// @Summary 告警规则列表
// @Tags 任务与日志回传
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [get]
func (h *logEventHandler) listAlertRules(c *gin.Context) {
rules, err := h.svc.ListAlertRules(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": rules})
}
// createAlertRule 创建告警规则;字段非法返回 400。
//
// @Summary 创建告警规则
// @Tags 任务与日志回传
// @Param body body alertRuleRequest true "请求体"
// @Success 201 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules [post]
func (h *logEventHandler) createAlertRule(c *gin.Context) {
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusCreated, rule)
}
// updateAlertRule 整体覆盖告警规则(含启停)。
//
// @Summary 更新告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Param body body alertRuleRequest true "请求体"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [put]
func (h *logEventHandler) updateAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
var req alertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel())
if err != nil {
respondAlertRuleError(c, err)
return
}
c.JSON(http.StatusOK, rule)
}
// deleteAlertRule 删除告警规则及其命中记录。
//
// @Summary 删除告警规则
// @Tags 任务与日志回传
// @Param ruleId path int true "规则 ID"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/log-events/alert-rules/{ruleId} [delete]
func (h *logEventHandler) deleteAlertRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
return
}
if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// respondAlertRuleError 把字段校验错误映射为 400。
func respondAlertRuleError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidAlertRule) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
respondError(c, err)
}
// getRelay 查询 OCI 侧链路状态与关键事件清单。
//
// @Summary 查询 OCI 侧链路状态与关键事件清单
+14 -2
View File
@@ -15,7 +15,8 @@ import (
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
const usernameKey = "username"
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
// 通过后把用户名放进上下文。
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
@@ -23,7 +24,7 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
username, err := auth.ParseToken(token)
username, err := auth.ParseToken(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
@@ -33,6 +34,17 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
}
}
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
func bodyLimit(n int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
}
c.Next()
}
}
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
+26 -9
View File
@@ -1,7 +1,10 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"log"
"net/http"
"strconv"
@@ -74,7 +77,7 @@ func (h *ociConfigHandler) create(c *gin.Context) {
func (h *ociConfigHandler) list(c *gin.Context) {
items, err := h.svc.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, err)
return
}
c.JSON(http.StatusOK, items)
@@ -168,6 +171,7 @@ func (h *ociConfigHandler) update(c *gin.Context) {
}
// @Summary 删除租户配置
// @Description 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
// @Tags 租户配置
// @Param id path int true "配置 ID"
// @Success 204 "无内容"
@@ -258,17 +262,29 @@ func pathID(c *gin.Context) (uint, bool) {
return uint(id), true
}
// respondError 统一出错响应边界(S-08):OCI 服务错误经脱敏透出,记录缺失
// 映射 404;其余按内部错误处理——响应只含固定文案与关联 ID,完整原因
// (可能携带 SQL / DSN / 路径等内部细节)仅写服务端日志,经同一 ID 对应。
func respondError(c *gin.Context, err error) {
status := http.StatusInternalServerError
if errors.Is(err, gorm.ErrRecordNotFound) {
status = http.StatusNotFound
}
var svcErr common.ServiceError
if errors.As(err, &svcErr) {
respondOCIError(c, err, svcErr)
return
}
c.JSON(status, gin.H{"error": err.Error()})
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
id := newRequestID()
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
}
// newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码,
@@ -278,9 +294,10 @@ func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) {
if status < http.StatusBadRequest {
status = http.StatusBadGateway
}
// 面板的 401 专属本地 JWT 失效(前端收到即登出)
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出
if status == http.StatusUnauthorized {
// 面板的 401 专属本地 JWT 失效(前端收到即登出)、429 专属本地 IP 限速
// (前端收到即跳 /blocked);上游 OCI 的同码是代理调用被拒/云端限流
// 均改用 502 透出,body 保留 ociCode 供辨识
if status == http.StatusUnauthorized || status == http.StatusTooManyRequests {
status = http.StatusBadGateway
}
body := gin.H{
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// fakeServiceError 模拟 oci-go-sdk 的 common.ServiceError,驱动 respondOCIError 分支。
type fakeServiceError struct {
status int
code string
message string
}
func (e fakeServiceError) Error() string {
return fmt.Sprintf("Error returned by service. Http Status Code: %d. Error Code: %s. Message: %s",
e.status, e.code, e.message)
}
func (e fakeServiceError) GetHTTPStatusCode() int { return e.status }
func (e fakeServiceError) GetMessage() string { return e.message }
func (e fakeServiceError) GetCode() string { return e.code }
func (e fakeServiceError) GetOpcRequestID() string { return "req-1" }
// TestRespondErrorOCIStatus 覆盖上游状态码改写规则:
// 401/429 在前端有本地专属语义(登出 / 跳 /blocked),上游同码须改写 502。
func TestRespondErrorOCIStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
upstream int
wantStatus int
}{
{"404 原样透传", 404, 404},
{"409 原样透传", 409, 409},
{"上游 401 改写 502", 401, 502},
{"上游 429 改写 502", 429, 502},
{"非错误码兜底 502", 200, 502},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
err := fmt.Errorf("获取流量: %w", fakeServiceError{tc.upstream, "TooManyRequests", "slow down"})
respondError(c, err)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
})
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func listRegions(c *gin.Context) {
regions, err := oci.AllRegions()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, err)
return
}
c.JSON(http.StatusOK, regions)
+2 -1
View File
@@ -21,7 +21,8 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
r.Use(accessLog, RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
v1 := r.Group("/api/v1")
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
v1 := r.Group("/api/v1", bodyLimit(1<<20))
registerAuthPublic(v1, auth, oauth, systemLogs)
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
registerAiGateway(r, aiGateway)
+110 -1
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -55,6 +57,13 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
}
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
t.Helper()
r, auth, systemLogs, _ := newTestRouterDB(t)
return r, auth, systemLogs
}
// newTestRouterDB 额外回传 db 句柄,供需要直插数据(如网关模型缓存)的测试使用。
func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
@@ -88,7 +97,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.Sy
logEvents := service.NewLogEventService(db)
oauth := service.NewOAuthService(db, settings, auth)
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
return r, auth, systemLogs
return r, auth, systemLogs, db
}
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
@@ -183,3 +192,103 @@ func TestSystemLogsEndpoint(t *testing.T) {
})
}
}
// TestLoginFieldLimits 超长登录字段被绑定校验拒绝(S-03 高基数防护第一道)。
func TestLoginFieldLimits(t *testing.T) {
r, _, _ := newTestRouter(t)
longName := strings.Repeat("a", 65)
longPass := strings.Repeat("b", 129)
tests := []struct {
name string
body string
}{
{name: "用户名超长", body: `{"username":"` + longName + `","password":"x"}`},
{name: "密码超长", body: `{"username":"admin","password":"` + longPass + `"}`},
{name: "验证码超长", body: `{"username":"admin","password":"x","totpCode":"123456789"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", tt.body)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
})
}
}
// TestBodyLimitRejectsHuge 面板 API 请求体超 1MB 被拒(S-03)。
func TestBodyLimitRejectsHuge(t *testing.T) {
r, _, _ := newTestRouter(t)
huge := `{"username":"admin","password":"` + strings.Repeat("x", 2<<20) + `"}`
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", huge)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400(body too large)", w.Code)
}
}
// TestRevokeSessionsEndpoint 撤销全部会话:直接调用即生效,
// 响应带新 token,旧 token 随即失效。
func TestRevokeSessionsEndpoint(t *testing.T) {
r, _, _ := newTestRouter(t)
login := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pass123"}`)
var sess struct{ Token string }
if err := json.Unmarshal(login.Body.Bytes(), &sess); err != nil || sess.Token == "" {
t.Fatalf("login: %s", login.Body.String())
}
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/revoke-sessions", sess.Token, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "token") {
t.Fatalf("revoke = %d %s", w.Code, w.Body.String())
}
w = doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", sess.Token, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code)
}
}
// TestRespondErrorSanitizesInternal 内部错误边界(S-08):wrap 链细节不透传,
// 响应为固定文案 + requestId;记录缺失映射 404 固定文案。
func TestRespondErrorSanitizesInternal(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
err error
wantStatus int
wantBody string // 必须出现
leaks []string // 不得出现
}{
{
name: "内部错误脱敏",
err: fmt.Errorf("query users: dial tcp 10.0.0.5:3306: dsn user:secretpw"),
wantStatus: http.StatusInternalServerError,
wantBody: "requestId",
leaks: []string{"secretpw", "10.0.0.5", "dsn", "dial tcp"},
},
{
name: "记录缺失固定文案",
err: fmt.Errorf("find oci config 5: %w", gorm.ErrRecordNotFound),
wantStatus: http.StatusNotFound,
wantBody: "资源不存在",
leaks: []string{"find oci config"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
respondError(c, tc.err)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
body := rec.Body.String()
if !strings.Contains(body, tc.wantBody) {
t.Errorf("body %q 应包含 %q", body, tc.wantBody)
}
for _, leak := range tc.leaks {
if strings.Contains(body, leak) {
t.Errorf("body %q 泄露内部细节 %q", body, leak)
}
}
})
}
}
+4 -2
View File
@@ -10,8 +10,7 @@ import (
// 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
aih := &aiGatewayHandler{gw: aiGateway}
ai := r.Group("/ai/v1", aih.auth)
ai.POST("/chat/completions", aih.chatCompletions)
ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
ai.POST("/responses", aih.responses)
ai.POST("/messages", aih.messages)
ai.POST("/embeddings", aih.embeddings)
@@ -33,6 +32,9 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
secured.GET("/ai-models", aiadmin.gatewayModels)
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
secured.GET("/ai-logs", aiadmin.listLogs)
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
}
+4 -3
View File
@@ -26,13 +26,14 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
ax := &authxHandler{auth: auth, oauth: oauth}
secured.GET("/auth/totp", ax.totpStatus)
secured.POST("/auth/totp/setup", ax.totpSetup)
secured.POST("/auth/totp/activate", ax.totpActivate)
secured.POST("/auth/totp/disable", ax.totpDisable)
secured.GET("/auth/credentials", ax.getCredentials)
secured.PUT("/auth/credentials", ax.updateCredentials)
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
secured.GET("/auth/identities", ax.identities)
secured.POST("/auth/totp/activate", ax.totpActivate)
secured.POST("/auth/totp/disable", ax.totpDisable)
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
}
+1
View File
@@ -109,6 +109,7 @@ func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/audit-events", h.getAuditEvents)
g.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
g.GET("/oci-configs/:id/domains", h.listIdentityDomains)
g.GET("/oci-configs/:id/users", h.listTenantUsers)
g.POST("/oci-configs/:id/users", h.createTenantUser)
g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
+3
View File
@@ -15,6 +15,9 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.GET("/settings/telegram", st.getTelegram)
secured.PUT("/settings/telegram", st.updateTelegram)
secured.POST("/settings/telegram/test", st.testTelegram)
secured.GET("/settings/notify-channels", st.listNotifyChannels)
secured.PUT("/settings/notify-channels/:type", st.updateNotifyChannel)
secured.POST("/settings/notify-channels/:type/test", st.testNotifyChannel)
secured.GET("/settings/notify-events", st.getNotifyEvents)
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
+4
View File
@@ -19,6 +19,10 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService,
le := &logEventHandler{svc: logEvents}
secured.GET("/log-events", le.list)
secured.GET("/log-events/alert-rules", le.listAlertRules)
secured.POST("/log-events/alert-rules", le.createAlertRule)
secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule)
secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule)
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
+76
View File
@@ -80,6 +80,82 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// listNotifyChannels 返回全部通知渠道的脱敏视图(webhook/ntfy/bark/smtp,不含 telegram)。
//
// @Summary 返回全部通知渠道的脱敏视图
// @Tags 设置
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels [get]
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
items, err := h.svc.NotifyChannels(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// updateNotifyChannelRequest 是保存单渠道配置的请求体;
// secret 承载渠道密文字段(ntfy token / bark device key / smtp 密码),
// 缺省沿用已存值,空串清除。
type updateNotifyChannelRequest struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
BodyTemplate string `json:"bodyTemplate"`
Server string `json:"server"`
Topic string `json:"topic"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
From string `json:"from"`
To string `json:"to"`
Secret *string `json:"secret"`
}
// updateNotifyChannel 保存单渠道配置并返回全渠道最新视图;未知类型或校验失败返回 400。
//
// @Summary 保存单渠道配置并返回全渠道最新视图
// @Tags 设置
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
// @Param body body updateNotifyChannelRequest true "请求体"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels/{type} [put]
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
var req updateNotifyChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.UpdateNotifyChannel(c.Request.Context(), c.Param("type"), service.UpdateNotifyChannelInput{
Enabled: req.Enabled, URL: req.URL, BodyTemplate: req.BodyTemplate,
Server: req.Server, Topic: req.Topic, Host: req.Host, Port: req.Port,
Username: req.Username, From: req.From, To: req.To, Secret: req.Secret,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.listNotifyChannels(c)
}
// testNotifyChannel 用已保存配置向指定渠道同步发送测试消息。
//
// @Summary 向指定渠道发送测试消息
// @Tags 设置
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels/{type}/test [post]
func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
if err := h.notifier.SendChannelTest(c.Request.Context(), c.Param("type")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
//
// @Summary 返回全部通知事件开关
+10 -5
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
@@ -155,10 +156,11 @@ func (h *taskHandler) logs(c *gin.Context) {
c.JSON(http.StatusOK, logs)
}
// @Summary 立即执行任务
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
// @Tags 任务与日志回传
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Success 202 {object} map[string]any
// @Failure 409 {object} map[string]any "任务正在执行中"
// @Security BearerAuth
// @Router /api/v1/tasks/{id}/run [post]
func (h *taskHandler) run(c *gin.Context) {
@@ -166,10 +168,13 @@ func (h *taskHandler) run(c *gin.Context) {
if !ok {
return
}
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
if err != nil {
if err := h.svc.TriggerTask(c.Request.Context(), id); err != nil {
if errors.Is(err, service.ErrTaskRunning) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, entry)
c.JSON(http.StatusAccepted, gin.H{"triggered": true})
}
+53 -22
View File
@@ -67,12 +67,14 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
// ---- 租户审计日志 ----
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24);
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)
//
// @Summary 实时查询租户 OCI 审计事件:hours 首查
// @Summary 批式懒加载查询租户 OCI 审计事件
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param cursor query string false "续查游标(上次响应原样带回)"
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/audit-events [get]
@@ -81,13 +83,10 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
if !ok {
return
}
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
q := service.AuditQuery{
Region: c.Query("region"), Hours: hours,
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
}
limit, _ := strconv.Atoi(c.Query("limit"))
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
if errors.Is(err, service.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) {
if errors.Is(err, service.ErrInvalidAuditCursor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -143,9 +142,29 @@ type createTenantUserRequest struct {
AddToAdminGroup bool `json:"addToAdminGroup"`
}
// @Summary 租户身份域列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/domains [get]
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
domains, err := h.svc.IdentityDomains(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, domains)
}
// @Summary 租户 IAM 用户列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users [get]
@@ -154,7 +173,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
if !ok {
return
}
users, err := h.svc.TenantUsers(c.Request.Context(), id)
users, err := h.svc.TenantUsers(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -165,6 +184,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
// @Summary 租户用户详情
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Security BearerAuth
@@ -174,7 +194,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
if !ok {
return
}
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Param("userId"))
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil {
respondError(c, err)
return
@@ -185,6 +205,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
// @Summary 创建租户 IAM 用户
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createTenantUserRequest true "请求体"
// @Success 201 {object} map[string]any
// @Security BearerAuth
@@ -199,7 +220,7 @@ func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, oci.CreateTenantUserInput{
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, c.Query("domainId"), oci.CreateTenantUserInput{
Name: req.Name,
Description: req.Description,
Email: req.Email,
@@ -229,6 +250,7 @@ type updateTenantUserRequest struct {
// @Summary 更新租户 IAM 用户
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Param body body updateTenantUserRequest true "请求体"
// @Success 200 {object} map[string]any
@@ -244,7 +266,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Param("userId"), oci.UpdateTenantUserInput{
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"), oci.UpdateTenantUserInput{
Description: req.Description,
Email: req.Email,
GivenName: req.GivenName,
@@ -262,6 +284,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
// @Summary 删除租户 IAM 用户
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 204 "无内容"
// @Security BearerAuth
@@ -271,7 +294,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
if !ok {
return
}
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Param("userId")); err != nil {
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId")); err != nil {
respondError(c, err)
return
}
@@ -281,6 +304,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
// @Summary 重置租户用户密码
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Security BearerAuth
@@ -290,7 +314,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
if !ok {
return
}
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Param("userId"))
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil {
respondError(c, err)
return
@@ -301,6 +325,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
// @Summary 清除用户 MFA 设备
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId"
// @Success 200 {object} map[string]any
// @Security BearerAuth
@@ -310,7 +335,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
if !ok {
return
}
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Param("userId"))
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil {
respondError(c, err)
return
@@ -344,6 +369,7 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
// @Summary ---- 域通知收件人与密码策略 ----
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
@@ -352,7 +378,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
if !ok {
return
}
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id)
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -363,6 +389,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
// @Summary 更新通知收件人
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
@@ -379,7 +406,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, req.Recipients)
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, c.Query("domainId"), req.Recipients)
if err != nil {
respondError(c, err)
return
@@ -390,6 +417,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
// @Summary 密码策略列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/password-policies [get]
@@ -398,7 +426,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
if !ok {
return
}
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id)
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -409,6 +437,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
// @Summary 更新密码策略
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param policyId path string true "policyId"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
@@ -428,7 +457,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Param("policyId"), oci.UpdatePasswordPolicyInput{
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Query("domainId"), c.Param("policyId"), oci.UpdatePasswordPolicyInput{
PasswordExpiresAfter: req.PasswordExpiresAfter,
MinLength: req.MinLength,
NumPasswordsInHistory: req.NumPasswordsInHistory,
@@ -443,6 +472,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
// @Summary 身份域设置
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
@@ -451,7 +481,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
if !ok {
return
}
setting, err := h.svc.IdentitySetting(c.Request.Context(), id)
setting, err := h.svc.IdentitySetting(c.Request.Context(), id, c.Query("domainId"))
if err != nil {
respondError(c, err)
return
@@ -462,6 +492,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
// @Summary 更新身份域设置
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any
// @Security BearerAuth
@@ -478,7 +509,7 @@ func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, *req.PrimaryEmailRequired)
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, c.Query("domainId"), *req.PrimaryEmailRequired)
if err != nil {
respondError(c, err)
return
+1 -1
View File
@@ -79,7 +79,7 @@ var wsUpgrader = websocket.Upgrader{
// @Success 101 "升级为 WebSocket"
// @Router /api/v1/console-sessions/{sessionId}/ws [get]
func (h *consoleHandler) ws(c *gin.Context) {
if _, err := h.auth.ParseToken(c.Query("token")); err != nil {
if _, err := h.auth.ParseToken(c.Request.Context(), c.Query("token")); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
+18 -3
View File
@@ -2,6 +2,9 @@ package database
import (
"fmt"
"log"
"os"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
@@ -12,6 +15,18 @@ import (
"oci-portal/internal/model"
)
// newDBLogger 构造脱敏的 GORM 日志器(S-08):SQL 一律参数化输出,
// 实参(密码哈希 / token / 任务 payload)不落日志;业务正常路径的
// record-not-found 不打 SQL。
func newDBLogger() logger.Interface {
return logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: true,
})
}
// Open 按驱动打开数据库并自动迁移全部模型。
// driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。
func Open(driver, dsn, path string) (*gorm.DB, error) {
@@ -20,7 +35,7 @@ func Open(driver, dsn, path string) (*gorm.DB, error) {
return nil, err
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
Logger: newDBLogger(),
})
if err != nil {
return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err)
@@ -52,8 +67,8 @@ func autoMigrate(db *gorm.DB) error {
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
&model.CheckSnapshot{}, &model.CostSnapshot{},
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
&model.SystemLog{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.Proxy{},
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
&model.AiContentLog{},
)
}
+51 -12
View File
@@ -10,11 +10,12 @@ const (
AccountTypeUnknown = "unknown"
)
// 测活状态取值。
// 测活状态取值;suspended 来自账户能力接口的云端暂停标记(区别于失联)
const (
AliveStatusAlive = "alive"
AliveStatusDead = "dead"
AliveStatusUnknown = "unknown"
AliveStatusAlive = "alive"
AliveStatusDead = "dead"
AliveStatusSuspended = "suspended"
AliveStatusUnknown = "unknown"
)
// Setting 是系统级键值配置;敏感值(如 telegram_bot_token)以 AES-GCM 密文存储。
@@ -64,9 +65,12 @@ type User struct {
Username string `gorm:"uniqueIndex;size:64" json:"username"`
PasswordHash string `json:"-"`
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
TotpSecretEnc string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
TotpSecretEnc string `json:"-"`
// TokenVersion 是 JWT 版本号:凭据/TOTP/外部身份/登录策略变更或撤销会话时
// 原子递增,旧版本令牌随即失效(存量令牌无 ver 视为 0,与零值兼容)
TokenVersion uint `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// UserIdentity 是管理员绑定的外部登录身份(OIDC / GitHub);
@@ -207,6 +211,31 @@ type LogEvent struct {
ReceivedAt time.Time `json:"receivedAt"`
}
// AlertRule 是回传事件的自定义告警规则;条件间 AND 关系,空条件视为任意。
type AlertRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64" json:"name"`
Enabled bool `json:"enabled"`
OciConfigID uint `json:"ociConfigId"` // 0=全部租户
EventTypes string `gorm:"size:512" json:"eventTypes"` // 逗号分隔事件短名,空=全部
SourceIPs string `gorm:"size:512" json:"sourceIps"` // 逗号分隔 IP/CIDR,空=任意
// in:来源命中列表才告警(默认);notin:不在列表才告警(白名单场景)
SourceIPMode string `gorm:"size:8" json:"sourceIpMode"`
ResourceMatch string `gorm:"size:128" json:"resourceMatch"` // 资源名子串,空=任意
Threshold int `json:"threshold"` // 触发阈值,默认 1(即时)
WindowMinutes int `json:"windowMinutes"` // 聚合窗口,Threshold>1 时必填
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// AlertRuleHit 记录规则命中,供阈值窗口计数;随周期清理删除过期行。
type AlertRuleHit struct {
ID uint `gorm:"primaryKey" json:"id"`
RuleID uint `gorm:"index" json:"ruleId"`
LogEventID uint `json:"logEventId"`
HitAt time.Time `gorm:"index" json:"hitAt"`
}
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
type RegionCache struct {
@@ -232,11 +261,13 @@ type CompartmentCache struct {
// AiKey 是 AI 网关的访问密钥;明文不落库,仅存 SHA-256 与尾 4 位。
// Group 非空时该密钥只在同分组渠道内负载均衡(列名避开 SQL 保留字 group)。
type AiKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:64" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
Tail string `gorm:"size:4" json:"tail"`
Group string `gorm:"column:key_group;size:32" json:"group"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:64" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
Tail string `gorm:"size:4" json:"tail"`
Group string `gorm:"column:key_group;size:32" json:"group"`
// Models 是模型白名单(精确匹配模型名);空 = 不限,非空时网关仅放行列表内模型
Models []string `gorm:"serializer:json;type:text" json:"models"`
Enabled bool `json:"enabled"`
LastUsedAt *time.Time `json:"lastUsedAt"`
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
@@ -282,6 +313,14 @@ type AiModelCache struct {
RetiredAt *time.Time `json:"retiredAt"`
}
// AiModelBlacklist 是全局模型黑名单(按模型名):加入即从全部渠道缓存删除该模型,
// 同步与探测过滤不再入库;适用于微调基座等实际不可按需调用的模型,由用户手动维护。
type AiModelBlacklist struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:96;uniqueIndex" json:"name"`
CreatedAt time.Time `json:"createdAt"`
}
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
type AiCallLog struct {
ID uint `gorm:"primaryKey" json:"id"`
+90
View File
@@ -1,9 +1,16 @@
package oci
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"oci-portal/internal/model"
)
@@ -19,6 +26,89 @@ type AccountProfile struct {
PromotionExpires *time.Time
}
// AccountCapabilities 是账户能力接口的关键字段。来源为 Limits 服务
// 20230301 版 `/compartments/{tenancy}/capabilities?accountInfoOnly=true`
// (与控制台同源;oci-go-sdk v65 未收录,自签名裸调)。
// 该接口对暂停/已终止租户仍可访问,借此把「暂停」从「失联」中区分出来。
type AccountCapabilities struct {
Suspended bool `json:"suspended"`
AccountStatus string `json:"accountStatus"`
FreeTierEnabled bool `json:"freeTierEnabled"`
FreeTierOnly bool `json:"freeTierOnly"`
PromotionStatus string `json:"promotionStatus"`
ProgramType string `json:"programType"`
HasSaasSubscription bool `json:"hasSaasSubscription"`
Deletable bool `json:"deletable"`
}
// capabilitiesEnvelope 对应接口原始响应:顶层与 accountInfo 各有一个
// suspended,任一为 true 即视为暂停。
type capabilitiesEnvelope struct {
Capabilities struct {
Suspended bool `json:"suspended"`
AccountInfo struct {
FreeTierEnabled bool `json:"freeTierEnabled"`
FreeTierOnly bool `json:"freeTierOnly"`
PromotionStatus string `json:"promotionStatus"`
Suspended bool `json:"suspended"`
ProgramType string `json:"programType"`
HasSaasSubscription bool `json:"hasSaasSubscription"`
AccountStatus string `json:"accountStatus"`
Deletable bool `json:"deletable"`
} `json:"accountInfo"`
} `json:"capabilities"`
}
// parseAccountCapabilities 把原始响应体压平为关键字段视图。
func parseAccountCapabilities(body []byte) (AccountCapabilities, error) {
var env capabilitiesEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return AccountCapabilities{}, fmt.Errorf("decode account capabilities: %w", err)
}
info := env.Capabilities.AccountInfo
return AccountCapabilities{
Suspended: env.Capabilities.Suspended || info.Suspended,
AccountStatus: info.AccountStatus,
FreeTierEnabled: info.FreeTierEnabled,
FreeTierOnly: info.FreeTierOnly,
PromotionStatus: info.PromotionStatus,
ProgramType: info.ProgramType,
HasSaasSubscription: info.HasSaasSubscription,
Deletable: info.Deletable,
}, nil
}
// GetAccountCapabilities 实现 Client:查询账户能力(暂停/账户状态/免费层)。
// region 应传 home region;请求带 API Key 签名,并沿用租户出站代理。
func (c *RealClient) GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error) {
url := fmt.Sprintf("https://limits.%s.oci.oraclecloud.com/20230301/compartments/%s/capabilities?accountInfoOnly=true",
normalizeRegion(region), cred.TenancyOCID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return AccountCapabilities{}, fmt.Errorf("new capabilities request: %w", err)
}
if err := common.DefaultRequestSigner(provider(cred)).Sign(req); err != nil {
return AccountCapabilities{}, fmt.Errorf("sign capabilities request: %w", err)
}
hc := proxyHTTPClient(cred.Proxy)
if hc == nil {
hc = &http.Client{Timeout: proxyClientTimeout}
}
resp, err := hc.Do(req)
if err != nil {
return AccountCapabilities{}, fmt.Errorf("get account capabilities: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return AccountCapabilities{}, fmt.Errorf("read account capabilities: %w", err)
}
if resp.StatusCode != http.StatusOK {
return AccountCapabilities{}, fmt.Errorf("get account capabilities: status %d", resp.StatusCode)
}
return parseAccountCapabilities(body)
}
// ClassifyAccount 按订阅字段判定账户类别:
// - paymentModel 非空且不是 FREE_TRIAL:付费;
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
+50
View File
@@ -36,3 +36,53 @@ func TestClassifyAccount(t *testing.T) {
})
}
}
// TestParseAccountCapabilities 用控制台同源接口的真实响应样例驱动。
func TestParseAccountCapabilities(t *testing.T) {
cases := []struct {
name string
body string
wantSusp bool
wantStatus string
wantErr bool
}{
{
name: "已终止租户(顶层与 accountInfo 双 suspended)",
body: `{"compartmentId":"ocid1.tenancy.oc1..x","capabilities":{"suspended":true,` +
`"accountInfo":{"freeTierEnabled":false,"freeTierOnly":false,"promotionStatus":"none",` +
`"suspended":true,"programType":"default","limitsProvisioned":true,"intentToPay":false,` +
`"hasSaasSubscription":true,"accountStatus":"terminated","accountFlags":0,"deletable":true,` +
`"limitIncrease":false,"orgProperties":"0"}}}`,
wantSusp: true,
wantStatus: "terminated",
},
{
name: "正常租户",
body: `{"capabilities":{"suspended":false,"accountInfo":{"accountStatus":"active","freeTierEnabled":true}}}`,
wantSusp: false, wantStatus: "active",
},
{
name: "仅 accountInfo 标记 suspended 也算暂停",
body: `{"capabilities":{"suspended":false,"accountInfo":{"suspended":true}}}`,
wantSusp: true,
},
{name: "非 JSON 报错", body: `<html>`, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAccountCapabilities([]byte(tc.body))
if tc.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.Suspended != tc.wantSusp || got.AccountStatus != tc.wantStatus {
t.Fatalf("got %+v, want suspended=%v status=%q", got, tc.wantSusp, tc.wantStatus)
}
})
}
}
+113 -1
View File
@@ -93,6 +93,11 @@ func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, regi
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
result.Items = appendKeptAuditEvents(result.Items, items)
}
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
for _, ev := range items {
out := toAuditEvent(ev)
if !keepAuditEvent(out) {
@@ -101,8 +106,115 @@ func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
if raw, mErr := json.Marshal(ev); mErr == nil {
out.Raw = raw
}
result.Items = append(result.Items, out)
dst = append(dst, out)
}
return dst
}
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
const (
auditWindowHours = 24
auditWindowMaxHours = 720
auditRetentionDays = 365
)
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
type AuditCursor struct {
Start time.Time `json:"s"`
End time.Time `json:"e"`
Page string `json:"p,omitempty"`
WindowHours int `json:"w"`
}
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
func NewAuditCursor(now time.Time) AuditCursor {
end := now.UTC().Truncate(time.Minute)
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
}
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
w := cur.WindowHours
if w <= 0 {
w = auditWindowHours
}
if empty {
if w *= 2; w > auditWindowMaxHours {
w = auditWindowMaxHours
}
} else {
w = auditWindowHours
}
end := cur.Start
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
return cur, true
}
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
}
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
// 表示已回溯到保留期尽头,无更早数据。
type AuditBatchResult struct {
Items []AuditEvent
Cursor *AuditCursor
Exhausted bool
}
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
ac, err := c.auditClient(cred, region)
if err != nil {
return AuditBatchResult{}, err
}
res := AuditBatchResult{Items: []AuditEvent{}}
windowHasKept := false
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
if err != nil {
return AuditBatchResult{}, err
}
before := len(res.Items)
res.Items = appendKeptAuditEvents(res.Items, items)
windowHasKept = windowHasKept || len(res.Items) > before
if next != "" {
cur.Page = next
continue
}
nextCur, done := cur.advance(time.Now(), !windowHasKept)
if done {
res.Exhausted = true
sortAuditEvents(res.Items)
return res, nil
}
cur, windowHasKept = nextCur, false
}
sortAuditEvents(res.Items)
res.Cursor = &cur
return res, nil
}
// listAuditPage 拉取当前游标位置的一页原始事件。
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
req := audit.ListEventsRequest{
CompartmentId: &tenancyOCID,
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
EndTime: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
}
if cur.Page != "" {
req.Page = &cur.Page
}
resp, err := ac.ListEvents(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list audit events: %w", err)
}
return resp.Items, deref(resp.OpcNextPage), nil
}
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
+45
View File
@@ -142,3 +142,48 @@ func TestKeepAuditEvent(t *testing.T) {
})
}
}
func TestAuditCursorAdvance(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
base := AuditCursor{
Start: now.Add(-24 * time.Hour),
End: now,
WindowHours: 24,
}
cases := []struct {
name string
cur AuditCursor
empty bool
wantHours int
wantDone bool
}{
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
{"空窗倍增", base, true, 48, false},
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next, done := tc.cur.advance(now, tc.empty)
if done != tc.wantDone {
t.Fatalf("done = %v, want %v", done, tc.wantDone)
}
if done {
return
}
if next.WindowHours != tc.wantHours {
t.Fatalf("WindowHours = %d, want %d", next.WindowHours, tc.wantHours)
}
if !next.End.Equal(tc.cur.Start) {
t.Fatalf("新窗 End = %v, 应紧邻上窗 Start %v", next.End, tc.cur.Start)
}
if got := next.End.Sub(next.Start); got != time.Duration(tc.wantHours)*time.Hour {
t.Fatalf("窗宽 = %v, want %dh", got, tc.wantHours)
}
if next.Page != "" {
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
}
})
}
}
+16 -1
View File
@@ -39,10 +39,18 @@ func ckey(cred Credentials, parts ...string) string {
// bust 写操作成功后失效该租户全部读缓存。
func (c *CachedClient) bust(err error, cred Credentials) {
if err == nil {
c.store.DeletePrefix(cred.TenancyOCID + "|")
c.InvalidateTenancy(cred.TenancyOCID)
}
}
// InvalidateTenancy 主动失效指定 tenancy 的全部 OCI 读缓存。
func (c *CachedClient) InvalidateTenancy(tenancyOCID string) {
if tenancyOCID == "" {
return
}
c.store.DeletePrefix(tenancyOCID + "|")
}
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
v, err := cache.Do(c.store, key, ttl, fn)
@@ -120,6 +128,13 @@ func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentia
})
}
// ListIdentityDomains 域列表准静态且是所有按域操作的 URL 解析源,长 TTL 缓存。
func (c *CachedClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
return cachedList(c, ckey(cred, "iddomains", region), cacheTTLStatic, func() ([]IdentityDomain, error) {
return c.Client.ListIdentityDomains(ctx, cred, region)
})
}
// ---- 写:直通,成功后按租户失效 ----
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
+15
View File
@@ -86,3 +86,18 @@ func TestCachedClientReturnsClone(t *testing.T) {
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
}
}
func TestCachedClientInvalidateTenancy(t *testing.T) {
inner := &countingClient{}
c := NewCachedClient(inner)
ctx := context.Background()
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
c.InvalidateTenancy("t1")
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
if inner.instCalls != 3 {
t.Fatalf("invalidate tenancy calls = %d, want 3", inner.instCalls)
}
}
+36 -24
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
@@ -99,10 +100,12 @@ type Client interface {
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error)
GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error)
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
// GenAiCompatResponses 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路)。
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error)
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
@@ -122,32 +125,41 @@ type Client interface {
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
// 批式懒加载走 ListAuditEventsBatch,本方法保留给详情小窗反查。
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error)
// ListAuditEventsBatch 从游标位置向更早方向收集约 limit 条保留事件,
// 分窗回溯(空窗倍增),跨批不重不漏;到 365 天保留期尽头置 Exhausted。
ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error)
// 身份域:租户 ACTIVE 域枚举(多域租户选择器数据源)。
ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error)
// 账户能力:暂停标记/账户状态/免费层信息(对暂停租户仍可访问,测活据此判「暂停」)。
GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error)
// 租户用户管理:domainID 非空按域走 Identity Domains SCIM,
// 为空保持经典 IAM 为主、新式租户自动回退的旧行为。
ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error)
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error)
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error)
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error)
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID 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)
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
// 域设置:通知收件人、密码策略、身份设置。
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, 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)
ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error)
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error)
UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error)
// FederationSAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error)
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (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
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
+88 -25
View File
@@ -47,26 +47,82 @@ type IdentitySettingInfo struct {
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
}
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
// IdentityDomain 是租户下一个 ACTIVE 身份域的列表视图;
// URL 仅供后端解析 SCIM 端点,不下发给前端(防面板被引导签名任意地址)。
type IdentityDomain struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
URL string `json:"-"`
HomeRegion string `json:"homeRegion"`
Type string `json:"type"`
LicenseType string `json:"licenseType"`
}
// ListIdentityDomains 实现 Client:列出租户全部 ACTIVE 身份域。
// 无域老租户返回空列表(非错误),调用方据此回退经典 IAM 路径。
func (c *RealClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
items, err := c.listDomainSummaries(ctx, cred, region)
if err != nil {
return nil, err
}
out := make([]IdentityDomain, 0, len(items))
for _, d := range items {
if d.LifecycleState != identity.DomainLifecycleStateActive {
continue
}
out = append(out, IdentityDomain{
ID: deref(d.Id),
DisplayName: deref(d.DisplayName),
URL: deref(d.Url),
HomeRegion: deref(d.HomeRegion),
Type: string(d.Type),
LicenseType: deref(d.LicenseType),
})
}
return out, nil
}
// listDomainSummaries 拉取租户全部域(数量极少,仍按游标翻全以防万一)。
func (c *RealClient) listDomainSummaries(ctx context.Context, cred Credentials, region string) ([]identity.DomainSummary, error) {
ic, err := c.identityClientAt(cred, region)
if err != nil {
return nil, err
}
var items []identity.DomainSummary
var page *string
for {
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID, Page: page})
if err != nil {
return nil, fmt.Errorf("list identity domains: %w", err)
}
items = append(items, resp.Items...)
if resp.OpcNextPage == nil {
return items, nil
}
page = resp.OpcNextPage
}
}
// domainURL 解析身份域的 endpoint URL:domainID 非空按 OCID 精确匹配
// (URL 只能来自租户自己的域列表,不信任外部输入),为空回退 Default 优先。
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region, domainID string) (string, error) {
items, err := c.listDomainSummaries(ctx, cred, region)
if err != nil {
return "", err
}
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
if err != nil {
return "", fmt.Errorf("list identity domains: %w", err)
}
url := pickDomainURL(resp.Items)
url := pickDomainURL(items, domainID)
if url == "" {
if domainID != "" {
return "", fmt.Errorf("identity domain %s not found or inactive", domainID)
}
return "", fmt.Errorf("no active identity domain found")
}
return url, nil
}
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
url, err := c.domainURL(ctx, cred, region)
// domainsClient 解析身份域(domainID 为空取 Default)并构造 SCIM 客户端。
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region, domainID string) (identitydomains.IdentityDomainsClient, error) {
url, err := c.domainURL(ctx, cred, region, domainID)
if err != nil {
return identitydomains.IdentityDomainsClient{}, err
}
@@ -80,13 +136,20 @@ func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region
return dc, nil
}
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
func pickDomainURL(domains []identity.DomainSummary) string {
// pickDomainURL 在 ACTIVE 域中选取:domainID 非空按 OCID 匹配;
// 为空优先名为 Default 的域,否则取第一个 ACTIVE 的域。
func pickDomainURL(domains []identity.DomainSummary, domainID string) string {
fallback := ""
for _, d := range domains {
if d.LifecycleState != identity.DomainLifecycleStateActive {
continue
}
if domainID != "" {
if deref(d.Id) == domainID {
return deref(d.Url)
}
continue
}
if deref(d.DisplayName) == "Default" {
return deref(d.Url)
}
@@ -98,8 +161,8 @@ func pickDomainURL(domains []identity.DomainSummary) string {
}
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return NotificationRecipients{}, err
}
@@ -115,8 +178,8 @@ func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credent
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return NotificationRecipients{}, err
}
@@ -153,8 +216,8 @@ func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDoma
}
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return nil, err
}
@@ -171,12 +234,12 @@ func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials,
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
ops := buildPolicyPatchOps(in)
if len(ops) == 0 {
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
}
dc, err := c.domainsClient(ctx, cred, region)
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return PasswordPolicyInfo{}, err
}
@@ -233,8 +296,8 @@ func removeOp(path string) identitydomains.Operations {
}
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return IdentitySettingInfo{}, err
}
@@ -254,12 +317,12 @@ func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, r
}
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
current, err := c.GetIdentitySetting(ctx, cred, region)
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
current, err := c.GetIdentitySetting(ctx, cred, region, domainID)
if err != nil {
return IdentitySettingInfo{}, err
}
dc, err := c.domainsClient(ctx, cred, region)
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return IdentitySettingInfo{}, err
}
+31
View File
@@ -76,3 +76,34 @@ func ServiceStatus(err error) (int, bool) {
}
return 0, false
}
// IsOnDemandUnsupported 识别「模型在该区域仅为微调基座、不支持按需调用」的 400:
// OCI 消息形如 "Not allowed to call finetune base model …, use Endpoint: false"。
// ListModels 无字段可事先区分,只能在调用报错时识别并换渠道。
func IsOnDemandUnsupported(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
return svcErr.GetHTTPStatusCode() == 400 &&
strings.Contains(strings.ToLower(svcErr.GetMessage()), "finetune base model")
}
// IsEntityNotFound 识别「实体不存在」404(消息 "Entity with key … not found"):
// GenAI 对区域内无按需供给的模型 OCID 返回此类 404,属模型级错误;
// 鉴权失败的 404 是 NotAuthorizedOrNotFound(消息为 Authorization failed…),不在此列。
func IsEntityNotFound(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
msg := strings.ToLower(svcErr.GetMessage())
return svcErr.GetHTTPStatusCode() == 404 &&
strings.Contains(msg, "entity with key") && strings.Contains(msg, "not found")
}
// IsModelUnavailable 归并「模型×区域不可按需调用」两类报错:微调基座 400 与实体不存在 404;
// 命中即应把该 (渠道, 模型) 从池中剔除并换候选/换渠道,而非定论租户配额问题。
func IsModelUnavailable(err error) bool {
return IsOnDemandUnsupported(err) || IsEntityNotFound(err)
}
+49
View File
@@ -109,3 +109,52 @@ func TestErrorHint(t *testing.T) {
})
}
}
func TestIsOnDemandUnsupported(t *testing.T) {
ft := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"微调基座 400 命中(含包装链)", fmt.Errorf("genai chat: %w", ft), true},
{"同消息但非 400 不命中", fakeServiceError{status: 500, code: "InternalError", message: ft.message}, false},
{"普通 400 不命中", fakeServiceError{status: 400, code: "InvalidParameter", message: "bad request"}, false},
{"非服务端错误不命中", errors.New("finetune base model"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOnDemandUnsupported(tt.err); got != tt.want {
t.Errorf("IsOnDemandUnsupported() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsModelUnavailable(t *testing.T) {
entity404 := fakeServiceError{status: 404, code: "NotFound",
message: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
auth404 := fakeServiceError{status: 404, code: "NotAuthorizedOrNotFound",
message: "Authorization failed or requested resource not found."}
ft400 := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1..x, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"实体不存在 404 命中(含包装链)", fmt.Errorf("genai chat: %w", entity404), true},
{"鉴权类 404 不命中(仍属租户级)", auth404, false},
{"微调基座 400 命中", ft400, true},
{"其他 404 消息不命中", fakeServiceError{status: 404, code: "NotFound", message: "route not found"}, false},
{"非服务端错误不命中", errors.New("entity with key x not found"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsModelUnavailable(tt.err); got != tt.want {
t.Errorf("IsModelUnavailable() = %v, want %v", got, tt.want)
}
})
}
}
+25 -20
View File
@@ -56,8 +56,8 @@ type CreateIdpInput struct {
}
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return nil, err
}
@@ -76,8 +76,8 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
}
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return IdentityProviderInfo{}, err
}
@@ -139,22 +139,27 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
return idp
}
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
count := 1
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
count := len(adminGroupNames)
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
Filter: &filter,
Attributes: common.String("id,displayName"),
Count: &count,
})
if err != nil {
return nil, fmt.Errorf("find Administrators group: %w", err)
return nil, fmt.Errorf("find administrators group: %w", err)
}
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
return nil, nil
for _, name := range adminGroupNames {
for _, g := range resp.Resources {
if deref(g.DisplayName) == name && g.Id != nil {
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: g.Id}, nil
}
}
}
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
return nil, nil
}
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
@@ -207,8 +212,8 @@ func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.Identity
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return IdentityProviderInfo{}, err
}
@@ -249,8 +254,8 @@ func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClie
}
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return err
}
@@ -346,8 +351,8 @@ func toggleJSONList(list, item string, add bool) (string, bool, error) {
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
url, err := c.domainURL(ctx, cred, region)
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) {
url, err := c.domainURL(ctx, cred, region, domainID)
if err != nil {
return nil, err
}
@@ -358,7 +363,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
if status == http.StatusOK {
return body, nil
}
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
return nil, err
}
body, status, err = fetchSamlMetadata(ctx, url)
@@ -390,8 +395,8 @@ func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
}
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return err
}
+59 -132
View File
@@ -2,13 +2,11 @@ package oci
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/generativeai"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
@@ -17,11 +15,10 @@ import (
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
type GenAiModel struct {
Ocid string `json:"ocid"`
Name string `json:"name"`
Vendor string `json:"vendor"`
ChatOnly bool `json:"-"`
Caps []string `json:"capabilities"`
Ocid string `json:"ocid"`
Name string `json:"name"`
Vendor string `json:"vendor"`
Caps []string `json:"capabilities"`
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
Capability string `json:"capability"`
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
@@ -31,12 +28,6 @@ type GenAiModel struct {
Retired *time.Time `json:"retired"`
}
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
type GenAiStream interface {
Next() (aiwire.ChatChunk, error)
Close() error
}
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
if err != nil {
@@ -71,18 +62,39 @@ func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, regi
if err != nil {
return nil, fmt.Errorf("list genai models: %w", err)
}
seen := map[string]bool{}
now := time.Now()
return dedupGenAiModels(resp.Items, time.Now()), nil
}
// dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目
// (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。
func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel {
seen := map[string]int{}
var out []GenAiModel
for _, m := range resp.Items {
for _, m := range items {
gm, ok := toGenAiModel(m, now)
if !ok || seen[gm.Name] {
if !ok {
continue
}
seen[gm.Name] = true
if i, dup := seen[gm.Name]; dup {
if hasFineTune(out[i].Caps) && !hasFineTune(gm.Caps) {
out[i] = gm
}
continue
}
seen[gm.Name] = len(out)
out = append(out, gm)
}
return out, nil
return out
}
// hasFineTune 判断能力列表是否含 FINE_TUNE(微调基座条目)。
func hasFineTune(caps []string) bool {
for _, c := range caps {
if c == string(generativeai.ModelCapabilityFineTune) {
return true
}
}
return false
}
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
@@ -132,121 +144,15 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
return out
}
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
req, err := buildChatRequest(ir, false)
if err != nil {
return nil, err
}
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
if err != nil {
return nil, fmt.Errorf("genai chat: %w", err)
}
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
}
// buildChatRequest 按模型 vendor 组装底层请求体。
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
if isCohereModel(ir.Model) {
return irToCohereSDK(ir, stream)
}
return irToSDK(ir, stream), nil
}
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
switch r := resp.(type) {
case generativeaiinference.GenericChatResponse:
return sdkToIR(r, model), nil
case generativeaiinference.CohereChatResponse:
return cohereSDKToIR(r, model), nil
default:
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
}
}
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
return generativeaiinference.ChatRequest{
ChatDetails: generativeaiinference.ChatDetails{
CompartmentId: &cred.TenancyOCID,
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
ChatRequest: req,
},
}
}
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
req, err := buildChatRequest(ir, true)
if err != nil {
return nil, err
}
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
if err != nil {
return nil, fmt.Errorf("genai chat stream: %w", err)
}
reader, err := common.NewSSEReader(resp.RawResponse)
if err != nil {
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
}
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
}
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
type genAiSSEStream struct {
reader *common.SseReader
body io.ReadCloser
model string
}
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
for {
data, err := s.reader.ReadNextEvent()
if err != nil {
return aiwire.ChatChunk{}, err
}
text := strings.TrimSpace(string(data))
if text == "" {
continue
}
if text == "[DONE]" {
return aiwire.ChatChunk{}, io.EOF
}
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
return chunk, nil
}
}
}
func (s *genAiSSEStream) Close() error {
if s.body != nil {
return s.body.Close()
}
return nil
}
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
// modelName 决定请求格式(cohere.* 走 COHERE)。
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
one := 1
ir := aiwire.ChatRequest{
Model: modelName,
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
MaxTokens: &one,
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
"max_output_tokens": 1, "store": false})
if err != nil {
return 0, err
}
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
if err == nil {
if _, err = c.GenAiCompatResponses(ctx, cred, region, body); err == nil {
return http.StatusOK, nil
}
if status, ok := ServiceStatus(err); ok {
@@ -255,6 +161,27 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
return 0, err
}
// sdkUsageToIR 把 SDK 用量转为内部记账结构(缓存命中挂 details,仅命中时出现)。
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
if u == nil {
return nil
}
out := &aiwire.Usage{}
if u.PromptTokens != nil {
out.PromptTokens = *u.PromptTokens
}
if u.CompletionTokens != nil {
out.CompletionTokens = *u.CompletionTokens
}
if u.TotalTokens != nil {
out.TotalTokens = *u.TotalTokens
}
if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens != nil && *u.PromptTokensDetails.CachedTokens > 0 {
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *u.PromptTokensDetails.CachedTokens}
}
return out
}
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
ic, err := c.genAiInferenceClient(cred, region)
-266
View File
@@ -1,266 +0,0 @@
package oci
import (
"encoding/json"
"fmt"
"strings"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。
func isCohereModel(name string) bool {
return strings.HasPrefix(strings.ToLower(name), "cohere.")
}
// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级
// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。
func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) {
var req generativeaiinference.CohereChatRequest
if err := cohereRejectUnsupported(ir); err != nil {
return req, err
}
p, err := cohereSplitMessages(ir.Messages)
if err != nil {
return req, err
}
req = generativeaiinference.CohereChatRequest{
Message: &p.message,
ChatHistory: p.history,
ToolResults: p.toolResults,
Tools: cohereTools(ir.Tools),
ResponseFormat: cohereResponseFormat(ir.ResponseFormat),
MaxTokens: ir.MaxTokens,
Temperature: ir.Temperature,
TopP: ir.TopP,
TopK: ir.TopK,
FrequencyPenalty: ir.FrequencyPenalty,
PresencePenalty: ir.PresencePenalty,
Seed: ir.Seed,
}
if p.preamble != "" {
req.PreambleOverride = &p.preamble
}
if len(ir.Stop) > 0 {
req.StopSequences = ir.Stop
}
if stream {
req.IsStream = &stream
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
}
return req, nil
}
// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。
func cohereRejectUnsupported(ir aiwire.ChatRequest) error {
for _, m := range ir.Messages {
for _, p := range m.Content.Parts {
if p.Type == "image_url" {
return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型")
}
}
}
return nil
}
// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。
type cohereParts struct {
message string
history []generativeaiinference.CohereMessage
preamble string
toolResults []generativeaiinference.CohereToolResult
}
// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message,
// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults
// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。
func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) {
lastUser, lastTool := -1, -1
for i, m := range msgs {
switch m.Role {
case "user":
lastUser = i
case "tool":
lastTool = i
}
}
if lastUser == -1 && lastTool == -1 {
return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息")
}
var p cohereParts
var preamble []string
calls := map[string]generativeaiinference.CohereToolCall{}
for i, m := range msgs {
text := m.Content.JoinText()
switch m.Role {
case "system", "developer":
preamble = append(preamble, text)
case "assistant":
p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls))
case "tool":
p.toolResults = append(p.toolResults, cohereToolResult(m, calls))
default: // user
if i == lastUser && lastUser > lastTool {
p.message = text
continue
}
p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text})
}
}
p.preamble = strings.Join(preamble, "\n")
return p, nil
}
// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。
func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage {
msg := generativeaiinference.CohereChatBotMessage{}
if text != "" {
msg.Message = &text
}
for _, tc := range tcs {
name := tc.Function.Name
var params interface{}
if json.Unmarshal([]byte(tc.Function.Arguments), &params) != nil || params == nil {
params = map[string]any{}
}
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
calls[tc.ID] = call
msg.ToolCalls = append(msg.ToolCalls, call)
}
return msg
}
// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id,
// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。
func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult {
call, ok := calls[m.ToolCallID]
if !ok {
name := m.ToolCallID
var params interface{} = map[string]any{}
call = generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
}
text := m.Content.JoinText()
var output interface{}
if json.Unmarshal([]byte(text), &output) != nil || output == nil {
output = map[string]any{"output": text}
}
if arr, isArr := output.([]interface{}); isArr {
return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr}
}
if _, isMap := output.(map[string]interface{}); !isMap {
output = map[string]any{"output": output}
}
return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}}
}
// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。
func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool {
if len(tools) == 0 {
return nil
}
out := make([]generativeaiinference.CohereTool, 0, len(tools))
for _, t := range tools {
name, desc := t.Function.Name, t.Function.Description
if desc == "" {
desc = name // Description 为 COHERE 必填
}
out = append(out, generativeaiinference.CohereTool{
Name: &name, Description: &desc,
ParameterDefinitions: cohereParams(t.Function.Parameters),
})
}
return out
}
// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。
func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition {
var s struct {
Properties map[string]struct {
Type string `json:"type"`
Description string `json:"description"`
} `json:"properties"`
Required []string `json:"required"`
}
if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 {
return nil
}
req := map[string]bool{}
for _, r := range s.Required {
req[r] = true
}
out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties))
for k, v := range s.Properties {
typ, desc := v.Type, v.Description
if typ == "" {
typ = "string"
}
pd := generativeaiinference.CohereParameterDefinition{Type: &typ}
if desc != "" {
pd.Description = &desc
}
if req[k] {
t := true
pd.IsRequired = &t
}
out[k] = pd
}
return out
}
// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。
func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat {
if rf == nil {
return nil
}
switch rf.Type {
case "json_object", "json_schema":
out := generativeaiinference.CohereResponseJsonFormat{}
var in struct {
Schema json.RawMessage `json:"schema"`
}
if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 {
var schema interface{}
if json.Unmarshal(in.Schema, &schema) == nil {
out.Schema = &schema
}
}
return out
}
return nil
}
// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。
func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse {
msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))}
for i, tc := range resp.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i))
}
finish := mapFinishReason(string(resp.FinishReason))
if len(msg.ToolCalls) > 0 {
finish = "tool_calls"
}
return &aiwire.ChatResponse{
Object: "chat.completion",
Model: model,
Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}},
Usage: sdkUsageToIR(resp.Usage),
}
}
// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。
func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall {
args := "{}"
if params != nil && *params != nil {
if b, err := json.Marshal(*params); err == nil {
args = string(b)
}
}
return aiwire.ToolCall{
ID: fmt.Sprintf("call_%s_%d", name, idx),
Type: "function",
Function: aiwire.FunctionCall{Name: name, Arguments: args},
}
}
-367
View File
@@ -1,367 +0,0 @@
package oci
import (
"encoding/json"
"strings"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。
func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest {
req := generativeaiinference.GenericChatRequest{
Messages: irMessages(ir.Messages),
MaxTokens: ir.MaxTokens,
MaxCompletionTokens: ir.MaxCompletionTokens,
Temperature: ir.Temperature,
TopP: ir.TopP,
TopK: ir.TopK,
FrequencyPenalty: ir.FrequencyPenalty,
PresencePenalty: ir.PresencePenalty,
Seed: ir.Seed,
NumGenerations: ir.N,
Tools: irTools(ir.Tools),
ToolChoice: irToolChoice(ir.ToolChoice),
ResponseFormat: irResponseFormat(ir.ResponseFormat),
}
if len(ir.Stop) > 0 {
req.Stop = ir.Stop
}
if ir.ReasoningEffort != "" {
req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort))
}
if stream {
req.IsStream = &stream
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
}
return req
}
// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。
func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message {
out := make([]generativeaiinference.Message, 0, len(msgs))
for _, m := range msgs {
switch m.Role {
case "system", "developer":
out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())})
case "assistant":
out = append(out, generativeaiinference.AssistantMessage{
Content: textContents(m.Content.JoinText()),
ToolCalls: irToolCalls(m.ToolCalls),
})
case "tool":
tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())}
if m.ToolCallID != "" {
id := m.ToolCallID
tm.ToolCallId = &id
}
out = append(out, tm)
default: // user
out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)})
}
}
return out
}
// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。
func chatContents(c aiwire.Content) []generativeaiinference.ChatContent {
if len(c.Parts) == 0 {
return textContents(c.Text)
}
var out []generativeaiinference.ChatContent
for _, p := range c.Parts {
switch {
case p.Type == "image_url" && p.ImageURL != nil:
img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL}
if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok {
img.Detail = d
}
out = append(out, generativeaiinference.ImageContent{ImageUrl: &img})
case p.Text != "":
t := p.Text
out = append(out, generativeaiinference.TextContent{Text: &t})
}
}
return out
}
// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。
func textContents(text string) []generativeaiinference.ChatContent {
if text == "" {
return nil
}
return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}}
}
func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall {
if len(calls) == 0 {
return nil
}
out := make([]generativeaiinference.ToolCall, 0, len(calls))
for _, c := range calls {
id, name, args := c.ID, c.Function.Name, c.Function.Arguments
out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args})
}
return out
}
func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition {
if len(tools) == 0 {
return nil
}
out := make([]generativeaiinference.ToolDefinition, 0, len(tools))
for _, t := range tools {
name, desc := t.Function.Name, t.Function.Description
fd := generativeaiinference.FunctionDefinition{Name: &name}
if desc != "" {
fd.Description = &desc
}
if len(t.Function.Parameters) > 0 {
var params interface{}
if json.Unmarshal(t.Function.Parameters, &params) == nil {
fd.Parameters = &params
}
}
out = append(out, fd)
}
return out
}
// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。
func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
switch s {
case "none":
return generativeaiinference.ToolChoiceNone{}
case "required":
return generativeaiinference.ToolChoiceRequired{}
case "auto":
return generativeaiinference.ToolChoiceAuto{}
}
return nil
}
var obj struct {
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" {
return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name}
}
return nil
}
func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat {
if rf == nil {
return nil
}
switch rf.Type {
case "json_object":
return generativeaiinference.JsonObjectResponseFormat{}
case "json_schema":
return irJSONSchemaFormat(rf.JSONSchema)
default:
return nil
}
}
// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。
func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat {
var in struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Strict *bool `json:"strict"`
}
if json.Unmarshal(raw, &in) != nil || in.Name == "" {
return generativeaiinference.JsonObjectResponseFormat{}
}
js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict}
if in.Description != "" {
js.Description = &in.Description
}
if len(in.Schema) > 0 {
var schema interface{}
if json.Unmarshal(in.Schema, &schema) == nil {
js.Schema = &schema
}
}
return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js}
}
// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。
func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse {
out := &aiwire.ChatResponse{Object: "chat.completion", Model: model}
if resp.TimeCreated != nil {
out.Created = resp.TimeCreated.Unix()
}
for i, ch := range resp.Choices {
idx := i
if ch.Index != nil {
idx = *ch.Index
}
out.Choices = append(out.Choices, aiwire.Choice{
Index: idx,
Message: sdkMessageToIR(ch.Message),
FinishReason: mapFinishReason(deref(ch.FinishReason)),
})
}
out.Usage = sdkUsageToIR(resp.Usage)
return out
}
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
if u == nil {
return nil
}
out := &aiwire.Usage{}
if u.PromptTokens != nil {
out.PromptTokens = *u.PromptTokens
}
if u.CompletionTokens != nil {
out.CompletionTokens = *u.CompletionTokens
}
if u.TotalTokens != nil {
out.TotalTokens = *u.TotalTokens
}
if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 {
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens}
}
return out
}
// sdkMessageToIR 提取助手消息文本与工具调用。
func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage {
out := aiwire.ChatMessage{Role: "assistant"}
am, ok := m.(generativeaiinference.AssistantMessage)
if !ok {
return out
}
var sb strings.Builder
for _, c := range am.Content {
if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil {
sb.WriteString(*tc.Text)
}
}
out.Content = aiwire.NewTextContent(sb.String())
for _, call := range am.ToolCalls {
if fc, ok := call.(generativeaiinference.FunctionCall); ok {
out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{
ID: deref(fc.Id),
Type: "function",
Function: aiwire.FunctionCall{
Name: deref(fc.Name),
Arguments: deref(fc.Arguments),
},
})
}
}
return out
}
// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。
func mapFinishReason(reason string) string {
switch strings.ToLower(reason) {
case "", "null":
return ""
case "stop", "completed", "end_turn":
return "stop"
case "length", "max_tokens":
return "length"
case "tool_calls", "tool_call", "tool_use":
return "tool_calls"
case "complete":
return "stop"
case "error_toxic":
return "content_filter"
default:
return strings.ToLower(reason)
}
}
// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。
type genAiStreamEvent struct {
Index *int `json:"index"`
Message struct {
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"toolCalls"`
} `json:"message"`
FinishReason *string `json:"finishReason"`
Usage *generativeaiinference.Usage `json:"usage"`
Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态
Text string `json:"text"` // COHERE 流事件的顶层增量文本
// CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级)
CohereCalls []struct {
Name string `json:"name"`
Parameters interface{} `json:"parameters"`
} `json:"toolCalls"`
}
// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。
func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) {
var ev genAiStreamEvent
if err := json.Unmarshal(data, &ev); err != nil {
return aiwire.ChatChunk{}, false
}
if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析
var inner genAiStreamEvent
if json.Unmarshal(ev.Choices[0], &inner) == nil {
inner.Usage = ev.Usage
ev = inner
}
}
chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model}
choice := aiwire.ChunkChoice{}
if ev.Index != nil {
choice.Index = *ev.Index
}
choice.Delta.Role = strings.ToLower(ev.Message.Role)
var sb strings.Builder
for _, c := range ev.Message.Content {
sb.WriteString(c.Text)
}
if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态
sb.WriteString(ev.Text)
}
choice.Delta.Content = sb.String()
for i, tc := range ev.Message.ToolCalls {
d := aiwire.ToolCallDelta{Index: i, ID: tc.ID}
if tc.ID != "" {
d.Type = "function"
}
d.Function.Name = tc.Name
d.Function.Arguments = tc.Arguments
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
}
for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID
call := cohereCallToIR(tc.Name, &tc.Parameters, i)
d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"}
d.Function.Name = call.Function.Name
d.Function.Arguments = call.Function.Arguments
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
}
if r := mapFinishReason(deref(ev.FinishReason)); r != "" {
choice.FinishReason = &r
}
hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil
if hasPayload {
chunk.Choices = []aiwire.ChunkChoice{choice}
}
chunk.Usage = sdkUsageToIR(ev.Usage)
return chunk, hasPayload || chunk.Usage != nil
}
@@ -0,0 +1,496 @@
package oci
// 真实 OCI GenAI effort 探针(重建版)。
//
// 前身 genai_cache_integration_test.go 承载缓存/亲和/服务端工具等历轮调研模式,
// 调研结论均已归档(.trellis/tasks/archive/2026-07/*/research/),该文件已删除;
// 本文件重建共享基础设施,保留 effort 支持矩阵探测,并新增 responses 端点探测
// (multi-agent 模型仅允许走 Responses 面)。
//
// 运行(默认跳过,不触网):
//
// OCI_GENAI_CACHE_PROBE=effort-matrix OCI_CACHE_DB=<db 绝对路径> DATA_KEY=<主密钥> \
// OCI_CACHE_RUN_ID=<唯一标识> go test ./internal/oci/ -run TestRealGenAiEffortMatrix -v
//
// 可选:OCI_CACHE_CONFIG_ID(默认 6)、OCI_CACHE_REGION(默认 us-chicago-1)、
// OCI_EFFORT_CASES 自定义用例("model=effort" 逗号分隔;effort 后缀 "@responses"
// 表示走 /actions/v1/responses 端点,如 "xai.grok-4.20-multi-agent=low@responses")。
//
// 纪律:数据库只读打开;OCI_GO_SDK_DEBUG 必须为空;日志不落原始请求体与凭据,
// 错误消息经 OCID 遮掩后截断。
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"gorm.io/gorm"
"gorm.io/gorm/logger"
projectcrypto "oci-portal/internal/crypto"
"oci-portal/internal/model"
)
const (
effortProbeRegion = "us-chicago-1"
effortProbeLimit = int64(4 << 20)
)
var effortRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
type effortProbeCase struct {
model string
effort string
responses bool // true 走 /actions/v1/responses,否则 typed /actions/chat
stream bool // 仅 responses 面:请求 SSE 流,观测事件类型序列
}
type effortProbeResources struct {
cred Credentials
region string
modelOCID map[string]string
inference generativeaiinference.GenerativeAiInferenceClient
}
type effortProbeResult struct {
status int
requestRef string
duration time.Duration
errorCode string
errorMessage string
completionTokens int
reasoningTokens int
answerLen int
outputTypes []string
}
// effortMatrixCases 默认批为 grok-4.3 五档基线;全模型矩阵结论见任务归档,
// 复测用 OCI_EFFORT_CASES 自定义。
func effortMatrixCases() []effortProbeCase {
if raw := os.Getenv("OCI_EFFORT_CASES"); raw != "" {
return parseEffortCases(raw)
}
return []effortProbeCase{
{model: "xai.grok-4.3", effort: "none", responses: true},
{model: "xai.grok-4.3", effort: "low", responses: true},
{model: "xai.grok-4.3", effort: "high", responses: true},
}
}
// parseEffortCases 解析 "model=effort[@responses|@responses-stream]" 逗号分隔用例。
func parseEffortCases(raw string) []effortProbeCase {
var out []effortProbeCase
for _, item := range strings.Split(raw, ",") {
parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
continue
}
effort, streaming := strings.CutSuffix(parts[1], "@responses-stream")
if streaming {
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true, stream: true})
continue
}
effort, _ = strings.CutSuffix(effort, "@responses") // 后缀兼容保留;所有用例均走 responses 面
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true})
}
return out
}
func TestRealGenAiEffortMatrix(t *testing.T) {
requireEffortProbeMode(t, "effort-matrix")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
resources := newEffortProbeResources(t, ctx)
for _, tc := range effortMatrixCases() {
runEffortCase(t, ctx, resources, tc)
}
}
func requireEffortProbeMode(t *testing.T, wanted string) {
t.Helper()
if os.Getenv("OCI_GENAI_CACHE_PROBE") != wanted {
t.Skipf("set OCI_GENAI_CACHE_PROBE=%s to run", wanted)
}
if os.Getenv("OCI_GO_SDK_DEBUG") != "" {
t.Fatal("OCI_GO_SDK_DEBUG must be unset to protect signed requests")
}
if !effortRunIDPattern.MatchString(os.Getenv("OCI_CACHE_RUN_ID")) {
t.Fatal("set a unique OCI_CACHE_RUN_ID (1-64 safe characters)")
}
}
func runEffortCase(t *testing.T, ctx context.Context, resources effortProbeResources, tc effortProbeCase) {
t.Helper()
ocid, ok := resources.modelOCID[tc.model]
if !ok {
t.Logf("model=%s effort=%s resolve-failed: model not in on-demand catalog", tc.model, tc.effort)
return
}
_ = ocid
body, path, err := effortProbeBody(tc)
if err != nil {
t.Fatalf("build effort body: %v", err)
}
result := executeEffortProbe(ctx, resources, path, body)
logEffortResult(t, tc, result)
}
// effortProbeBody 构造 OpenAI Responses 直通请求体(typed chat 面已随网关剔除,
// 探针仅保留 responses 端点;历史 typed 结论见任务归档)。
func effortProbeBody(tc effortProbeCase) ([]byte, string, error) {
question := "How many positive divisors does 360 have? Answer with the number only."
body := map[string]interface{}{"model": tc.model, "input": question,
"max_output_tokens": 2048, "store": false}
if tc.effort != "" {
body["reasoning"] = map[string]string{"effort": tc.effort}
}
if tc.stream {
body["stream"] = true
}
payload, err := json.Marshal(body)
return payload, "/actions/v1/responses", err
}
// executeEffortProbe 以 IAM 签名裸 POST 指定路径;responses 面按生产实现补 compartment 头。
func executeEffortProbe(ctx context.Context, resources effortProbeResources, path string, body []byte) effortProbeResult {
client := resources.inference.BaseClient
client.Configuration.CircuitBreaker = nil
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, path, strings.NewReader(string(body)))
if err != nil {
return effortProbeResult{errorCode: "BuildRequest", errorMessage: err.Error()}
}
request.Header.Set("Content-Type", "application/json")
if path != "/actions/chat" {
request.Header.Set("CompartmentId", resources.cred.TenancyOCID)
request.Header.Set("opc-compartment-id", resources.cred.TenancyOCID)
}
return effortCallProbe(ctx, client, request)
}
func effortCallProbe(ctx context.Context, client common.BaseClient, request *http.Request) effortProbeResult {
started := time.Now()
response, err := client.Call(ctx, request)
result := effortProbeResult{duration: time.Since(started)}
if response != nil {
result.status = response.StatusCode
result.requestRef = shortEffortRef(response.Header.Get("opc-request-id"))
defer response.Body.Close()
}
if err != nil {
result.errorCode, result.errorMessage = effortProbeError(err)
return result
}
payload, readErr := io.ReadAll(io.LimitReader(response.Body, effortProbeLimit))
if readErr != nil {
result.errorCode, result.errorMessage = "ReadResponse", readErr.Error()
return result
}
parseEffortPayload(&result, payload)
return result
}
func effortProbeError(err error) (string, string) {
if serviceErr, ok := common.IsServiceError(err); ok {
return fmt.Sprintf("%d", serviceErr.GetHTTPStatusCode()), sanitizeEffortText(serviceErr.GetMessage())
}
return "CallError", sanitizeEffortText(err.Error())
}
// parseEffortPayload 兼容三种 usage 形态:typed 驼峰(chatResponse.usage.completionTokens)、
// chat 兼容面下划线(usage.completion_tokens)、responses 面(usage.output_tokens)。
func parseEffortPayload(result *effortProbeResult, payload []byte) {
if text := strings.TrimSpace(string(payload)); strings.HasPrefix(text, "event:") || strings.HasPrefix(text, "data:") {
parseEffortSSE(result, text)
return
}
var root map[string]interface{}
if json.Unmarshal(payload, &root) != nil {
return
}
result.answerLen = len(parseEffortAnswer(root))
result.outputTypes = parseEffortOutputTypes(root)
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
root = typed
}
usage, _ := root["usage"].(map[string]interface{})
for _, key := range []string{"completionTokens", "completion_tokens", "output_tokens"} {
if v, ok := effortInt(usage, key); ok {
result.completionTokens = v
break
}
}
for _, detailsKey := range []string{"completionTokensDetails", "completion_tokens_details", "output_tokens_details"} {
details, _ := usage[detailsKey].(map[string]interface{})
for _, key := range []string{"reasoningTokens", "reasoning_tokens"} {
if v, ok := effortInt(details, key); ok {
result.reasoningTokens = v
}
}
}
}
// parseEffortAnswer 提取正文文本:typed 取 chatResponse.choices[].message.content[].text,
// responses 取 output[] 里 message 项的 content[].text。仅在内存中量长度,不落日志。
func parseEffortAnswer(root map[string]interface{}) string {
var sb strings.Builder
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
choices, _ := typed["choices"].([]interface{})
for _, c := range choices {
cm, _ := c.(map[string]interface{})
msg, _ := cm["message"].(map[string]interface{})
collectEffortText(&sb, msg["content"])
}
return sb.String()
}
output, _ := root["output"].([]interface{})
for _, item := range output {
im, _ := item.(map[string]interface{})
if im["type"] == "message" {
collectEffortText(&sb, im["content"])
}
}
return sb.String()
}
func collectEffortText(sb *strings.Builder, content interface{}) {
parts, _ := content.([]interface{})
for _, p := range parts {
pm, _ := p.(map[string]interface{})
if text, ok := pm["text"].(string); ok {
sb.WriteString(text)
}
}
}
// parseEffortSSE 汇总 SSE 流:事件类型去重序列进 outputTypes,正文增量长度进 answerLen,
// 末尾 completed 事件里的 usage 进 tokens 字段。
func parseEffortSSE(result *effortProbeResult, text string) {
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
if name, ok := strings.CutPrefix(line, "event: "); ok {
if name = strings.TrimSpace(name); !seen[name] {
seen[name] = true
result.outputTypes = append(result.outputTypes, name)
}
continue
}
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var ev map[string]interface{}
if json.Unmarshal([]byte(data), &ev) != nil {
continue
}
if name, ok := ev["type"].(string); ok && !seen[name] { // 纯 data: 行流(无 event: 行)从事件体取类型
seen[name] = true
result.outputTypes = append(result.outputTypes, name)
}
if delta, ok := ev["delta"].(string); ok && strings.Contains(fmt.Sprint(ev["type"]), "output_text") {
result.answerLen += len(delta)
}
if resp, ok := ev["response"].(map[string]interface{}); ok {
usage, _ := resp["usage"].(map[string]interface{})
if v, ok := effortInt(usage, "output_tokens"); ok {
result.completionTokens = v
}
}
}
}
// parseEffortOutputTypes 收集 responses 面 output 项类型序列(multi-agent 行为观测)。
func parseEffortOutputTypes(root map[string]interface{}) []string {
output, _ := root["output"].([]interface{})
var types []string
for _, item := range output {
im, _ := item.(map[string]interface{})
if s, ok := im["type"].(string); ok {
types = append(types, s)
}
}
return types
}
func effortInt(root map[string]interface{}, key string) (int, bool) {
if root == nil {
return 0, false
}
if value, ok := root[key].(float64); ok {
return int(value), true
}
return 0, false
}
func logEffortResult(t *testing.T, tc effortProbeCase, result effortProbeResult) {
t.Helper()
endpoint := "typed-chat"
if tc.responses {
endpoint = "compat-responses"
}
t.Logf("endpoint=%s model=%s effort=%s status=%d completion=%d reasoning=%d answer_len=%d output_types=%v request=%s duration_ms=%d error=%s message=%q",
endpoint, tc.model, tc.effort, result.status, result.completionTokens, result.reasoningTokens,
result.answerLen, result.outputTypes, result.requestRef, result.duration.Milliseconds(),
result.errorCode, result.errorMessage)
}
// sanitizeEffortText 遮掩 OCID 并截断,避免错误消息携带租户可定位信息。
func sanitizeEffortText(text string) string {
text = regexp.MustCompile(`ocid1\.[a-z0-9._-]+`).ReplaceAllString(text, "ocid1.***")
if len(text) > 300 {
text = text[:300] + "..."
}
return text
}
func shortEffortRef(ref string) string {
if len(ref) > 12 {
return ref[:12]
}
return ref
}
func effortEnvOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
// newEffortProbeResources 装配凭据、区域、inference 客户端与按需模型目录。
func newEffortProbeResources(t *testing.T, ctx context.Context) effortProbeResources {
t.Helper()
cred := effortProbeCredentials(t)
region := effortEnvOr("OCI_CACHE_REGION", effortProbeRegion)
cred.Region = region
real := &RealClient{}
ic, err := real.genAiInferenceClient(cred, region)
if err != nil {
t.Fatalf("new inference client: %v", err)
}
models, err := real.ListGenAiModels(ctx, cred, region)
if err != nil {
t.Fatalf("list models: %s", sanitizeEffortText(err.Error()))
}
index := make(map[string]string, len(models))
for _, m := range models {
index[m.Name] = m.Ocid
}
return effortProbeResources{cred: cred, region: region, modelOCID: index, inference: ic}
}
// effortProbeCredentials 优先从面板数据库(只读)取渠道凭据,否则退回本地 ini 测试凭据。
func effortProbeCredentials(t *testing.T) Credentials {
t.Helper()
if dbPath := os.Getenv("OCI_CACHE_DB"); dbPath != "" {
return effortDBCredentials(t, dbPath)
}
return loadTestCredentials(t, effortEnvOr("OCI_TEST_KEY", "试用期"))
}
func effortDBCredentials(t *testing.T, dbPath string) Credentials {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=ro", dbPath)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open read-only probe database: %v", err)
}
cipher, err := projectcrypto.NewCipher(os.Getenv("DATA_KEY"))
if err != nil {
t.Fatalf("create data cipher: %v", err)
}
var config model.OciConfig
if err := db.First(&config, effortEnvOr("OCI_CACHE_CONFIG_ID", "6")).Error; err != nil {
t.Fatalf("load OCI config: %v", err)
}
return decryptEffortCredentials(t, db, cipher, config)
}
func decryptEffortCredentials(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, config model.OciConfig) Credentials {
t.Helper()
privateKey, err := cipher.DecryptString(config.PrivateKeyEnc)
if err != nil {
t.Fatalf("decrypt OCI config %d private key: %v", config.ID, err)
}
passphrase := ""
if config.PassphraseEnc != "" {
if passphrase, err = cipher.DecryptString(config.PassphraseEnc); err != nil {
t.Fatalf("decrypt OCI config %d passphrase: %v", config.ID, err)
}
}
return Credentials{TenancyOCID: config.TenancyOCID, UserOCID: config.UserOCID,
Fingerprint: config.Fingerprint, Region: config.Region, PrivateKey: privateKey,
Passphrase: passphrase, Proxy: loadEffortProxy(t, db, cipher, config.ProxyID)}
}
func loadEffortProxy(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, proxyID *uint) *ProxySpec {
t.Helper()
if proxyID == nil {
return nil
}
var proxy model.Proxy
if err := db.First(&proxy, *proxyID).Error; err != nil {
t.Fatalf("load proxy %d: %v", *proxyID, err)
}
password := ""
if proxy.PasswordEnc != "" {
decrypted, err := cipher.DecryptString(proxy.PasswordEnc)
if err != nil {
t.Fatalf("decrypt proxy %d password: %v", *proxyID, err)
}
password = decrypted
}
return &ProxySpec{Type: proxy.Type, Host: proxy.Host, Port: proxy.Port,
Username: proxy.Username, Password: password}
}
// TestParseEffortCases 断言自定义用例解析:端点后缀、空项与畸形项跳过。
func TestParseEffortCases(t *testing.T) {
got := parseEffortCases("a=low, b=high@responses ,bad,=x,c=")
want := []effortProbeCase{{model: "a", effort: "low", responses: true}, {model: "b", effort: "high", responses: true}}
if len(got) != len(want) {
t.Fatalf("parseEffortCases = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("case %d = %+v, want %+v", i, got[i], want[i])
}
}
}
// TestParseEffortPayload 断言三种 usage 形态与 output 类型序列的解析。
func TestParseEffortPayload(t *testing.T) {
cases := []struct {
name string
payload string
completion int
reasoning int
types int
}{
{"typed 驼峰", `{"chatResponse":{"choices":[{"message":{"content":[{"type":"TEXT","text":"24"}]}}],"usage":{"completionTokens":5,"completionTokensDetails":{"reasoningTokens":9}}}}`, 5, 9, 0},
{"responses 面", `{"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"24"}]}],"usage":{"output_tokens":7,"output_tokens_details":{"reasoning_tokens":3}}}`, 7, 3, 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var result effortProbeResult
parseEffortPayload(&result, []byte(tc.payload))
if result.completionTokens != tc.completion || result.reasoningTokens != tc.reasoning || len(result.outputTypes) != tc.types {
t.Fatalf("parse = %+v, want completion=%d reasoning=%d types=%d", result, tc.completion, tc.reasoning, tc.types)
}
if result.answerLen != 2 {
t.Fatalf("answerLen = %d, want 2", result.answerLen)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
package oci
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/oracle/oci-go-sdk/v65/common"
)
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
const compatResponsesLimit = int64(8 << 20)
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
// `/20231130/actions/v1/responses`(IAM 签名)。该端点实测可执行 xAI 服务端工具
// (web_search / x_search),但不在 Oracle 文档化工具白名单内,行为可能随服务
// 版本、模型或区域变化;调用方须自行校验并改写请求体(store/stream)。
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build compat responses request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
return nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
if err != nil {
return nil, fmt.Errorf("read compat responses body: %w", err)
}
return payload, nil
}
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build compat responses stream request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
if response != nil && response.Body != nil {
response.Body.Close()
}
return nil, err
}
return response.Body, nil
}
+30
View File
@@ -38,3 +38,33 @@ func TestToGenAiModelRetiredFilter(t *testing.T) {
t.Error("正常模型应入池")
}
}
func TestDedupGenAiModelsFineTunePreference(t *testing.T) {
now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
chatFT := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat, generativeai.ModelCapabilityFineTune}
mk := func(id, name string, caps []generativeai.ModelCapabilityEnum) generativeai.ModelSummary {
return generativeai.ModelSummary{
Id: common.String(id), DisplayName: common.String(name), Vendor: common.String("meta"),
Capabilities: caps, LifecycleState: generativeai.ModelLifecycleStateActive,
}
}
const name = "meta.llama-3-70b-instruct"
tests := []struct {
name string
items []generativeai.ModelSummary
want string // 期望保留的 OCID
}{
{"基座在前、纯对话在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-ft", name, chatFT), mk("o-od", name, chat)}, "o-od"},
{"纯对话在前、基座在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-od", name, chat), mk("o-ft", name, chatFT)}, "o-od"},
{"仅基座条目:保留不误删", []generativeai.ModelSummary{mk("o-ft", name, chatFT)}, "o-ft"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dedupGenAiModels(tt.items, now)
if len(got) != 1 || got[0].Ocid != tt.want {
t.Errorf("dedupGenAiModels() = %+v, want 仅保留 %s", got, tt.want)
}
})
}
}
+10 -176
View File
@@ -1,193 +1,27 @@
package oci
import (
"encoding/json"
"testing"
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
"oci-portal/internal/aiwire"
)
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射
func TestIrToSDK(t *testing.T) {
temp, mt := 0.7, 100
ir := aiwire.ChatRequest{
Model: "meta.llama-3.3-70b-instruct",
Messages: []aiwire.ChatMessage{
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
{Role: "user", Content: aiwire.NewTextContent("你好")},
{Role: "assistant", ToolCalls: []aiwire.ToolCall{{ID: "c1", Type: "function", Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}},
{Role: "tool", ToolCallID: "c1", Content: aiwire.NewTextContent("晴")},
},
Temperature: &temp,
MaxTokens: &mt,
Stop: aiwire.StringList{"END"},
Tools: []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}}},
ToolChoice: json.RawMessage(`"auto"`),
}
req := irToSDK(ir, true)
if len(req.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(req.Messages))
}
if _, ok := req.Messages[0].(generativeaiinference.SystemMessage); !ok {
t.Fatalf("messages[0] = %T, want SystemMessage", req.Messages[0])
}
am, ok := req.Messages[2].(generativeaiinference.AssistantMessage)
if !ok || len(am.ToolCalls) != 1 {
t.Fatalf("assistant tool calls 未映射: %T %+v", req.Messages[2], am)
}
tm, ok := req.Messages[3].(generativeaiinference.ToolMessage)
if !ok || deref(tm.ToolCallId) != "c1" {
t.Fatalf("tool message 未映射 toolCallId: %+v", tm)
}
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("temperature 未直通")
}
if req.MaxTokens == nil || *req.MaxTokens != 100 || len(req.Stop) != 1 {
t.Fatalf("maxTokens/stop 未直通")
}
if _, ok := req.ToolChoice.(generativeaiinference.ToolChoiceAuto); !ok {
t.Fatalf("toolChoice = %T, want auto", req.ToolChoice)
}
if len(req.Tools) != 1 || req.IsStream == nil || !*req.IsStream || req.StreamOptions == nil {
t.Fatalf("tools/stream 选项未映射")
}
}
// TestSdkToIR 断言响应文本、工具调用与用量的反向映射。
func TestSdkToIR(t *testing.T) {
text, fr := "东京晴", "tool_calls"
idx, pt, ct, tt := 0, 10, 5, 15
id, name, args := "c1", "get_weather", `{"city":"东京"}`
resp := generativeaiinference.GenericChatResponse{
Choices: []generativeaiinference.ChatChoice{{
Index: &idx,
Message: generativeaiinference.AssistantMessage{
Content: []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}},
ToolCalls: []generativeaiinference.ToolCall{generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}},
},
FinishReason: &fr,
}},
Usage: &generativeaiinference.Usage{PromptTokens: &pt, CompletionTokens: &ct, TotalTokens: &tt},
}
out := sdkToIR(resp, "m1")
if len(out.Choices) != 1 || out.Choices[0].Message.Content.JoinText() != "东京晴" {
t.Fatalf("文本未映射: %+v", out)
}
if out.Choices[0].FinishReason != "tool_calls" || len(out.Choices[0].Message.ToolCalls) != 1 {
t.Fatalf("finishReason/toolCalls 未映射: %+v", out.Choices[0])
}
if out.Usage == nil || out.Usage.TotalTokens != 15 {
t.Fatalf("usage 未映射: %+v", out.Usage)
}
}
// TestParseGenAiEvent 断言流事件宽容解析:文本增量、结束原因与 usage 事件。
func TestParseGenAiEvent(t *testing.T) {
chunk, ok := parseGenAiEvent([]byte(`{"index":0,"message":{"role":"ASSISTANT","content":[{"type":"TEXT","text":"你"}]}}`), "m1")
if !ok || len(chunk.Choices) != 1 || chunk.Choices[0].Delta.Content != "你" {
t.Fatalf("文本增量解析失败: %+v", chunk)
}
chunk, ok = parseGenAiEvent([]byte(`{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":2,"totalTokens":5}}`), "m1")
if !ok || chunk.Choices[0].FinishReason == nil || *chunk.Choices[0].FinishReason != "stop" {
t.Fatalf("finishReason 解析失败: %+v", chunk)
}
if chunk.Usage == nil || chunk.Usage.TotalTokens != 5 {
t.Fatalf("usage 解析失败: %+v", chunk.Usage)
}
if _, ok := parseGenAiEvent([]byte(`{}`), "m1"); ok {
t.Fatal("空事件应返回 ok=false")
}
}
// TestSdkUsageCachedTokens 断言 embed 用量映射:缓存命中挂 details,仅命中 >0 时出现
func TestSdkUsageCachedTokens(t *testing.T) {
p, c, tot, cached := 10, 5, 15, 8
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
if u.CachedTokens() != 8 {
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
if u.PromptTokens != 10 || u.CompletionTokens != 5 || u.TotalTokens != 15 {
t.Fatalf("usage 映射错误: %+v", u)
}
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
t.Error("无细分时 CachedTokens 应为 0")
if u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 8 {
t.Fatalf("cachedTokens 未映射: %+v", u.PromptTokensDetails)
}
}
func TestIrToCohereSDK(t *testing.T) {
ir := aiwire.ChatRequest{
Model: "cohere.command-r-plus",
Messages: []aiwire.ChatMessage{
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
{Role: "user", Content: aiwire.NewTextContent("第一问")},
{Role: "assistant", Content: aiwire.NewTextContent("第一答")},
{Role: "user", Content: aiwire.NewTextContent("第二问")},
},
zero := 0
if got := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &zero}}); got.PromptTokensDetails != nil {
t.Fatalf("零命中不应带 details: %+v", got.PromptTokensDetails)
}
req, err := irToCohereSDK(ir, true)
if err != nil {
t.Fatalf("irToCohereSDK: %v", err)
}
if *req.Message != "第二问" || *req.PreambleOverride != "你是助手" || len(req.ChatHistory) != 2 {
t.Errorf("拆装错误: msg=%q preamble=%v history=%d", *req.Message, req.PreambleOverride, len(req.ChatHistory))
}
if _, ok := req.ChatHistory[0].(generativeaiinference.CohereUserMessage); !ok {
t.Errorf("history[0] 应为 user: %T", req.ChatHistory[0])
}
if _, ok := req.ChatHistory[1].(generativeaiinference.CohereChatBotMessage); !ok {
t.Errorf("history[1] 应为 chatbot: %T", req.ChatHistory[1])
}
if req.IsStream == nil || !*req.IsStream {
t.Error("IsStream 未设置")
}
if req.StreamOptions == nil || req.StreamOptions.IsIncludeUsage == nil || !*req.StreamOptions.IsIncludeUsage {
t.Error("流式应默认开启 usage 回传(StreamOptions.IsIncludeUsage)")
}
// 工具定义降级为扁平参数表(仅取 JSON Schema 顶层 properties)
ir.Tools = []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{
Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string","description":"城市"}},"required":["city"]}`)}}}
req2, err := irToCohereSDK(ir, false)
if err != nil {
t.Fatalf("工具请求应支持: %v", err)
}
tool := req2.Tools[0]
if *tool.Name != "get_weather" || *tool.Description != "get_weather" {
t.Errorf("tool = %+v", tool)
}
if pd, ok := tool.ParameterDefinitions["city"]; !ok || *pd.Type != "string" || pd.IsRequired == nil || !*pd.IsRequired {
t.Errorf("param city = %+v", pd)
}
// 图片输入仍拒绝
ir.Tools = nil
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "user", Content: aiwire.NewPartsContent([]aiwire.ContentPart{
{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: "data:image/png;base64,xx"}}})})
if _, err := irToCohereSDK(ir, false); err == nil {
t.Error("图片输入应被拒绝")
}
// 无 user 消息被拒
if _, err := irToCohereSDK(aiwire.ChatRequest{Model: "cohere.x", Messages: []aiwire.ChatMessage{{Role: "system", Content: aiwire.NewTextContent("s")}}}, false); err == nil {
t.Error("无 user 消息应被拒绝")
}
}
func TestCohereSDKToIR(t *testing.T) {
text := "回答"
resp := generativeaiinference.CohereChatResponse{
Text: &text,
FinishReason: generativeaiinference.CohereChatResponseFinishReasonComplete,
}
out := cohereSDKToIR(resp, "cohere.command-r-plus")
if out.Choices[0].Message.Content.JoinText() != "回答" || out.Choices[0].FinishReason != "stop" {
t.Errorf("cohereSDKToIR = %+v", out.Choices[0])
}
}
func TestParseGenAiEventCohere(t *testing.T) {
chunk, ok := parseGenAiEvent([]byte(`{"apiFormat":"COHERE","text":"你好"}`), "cohere.command-r")
if !ok || chunk.Choices[0].Delta.Content != "你好" {
t.Errorf("cohere text 事件解析 = %+v, %v", chunk, ok)
}
chunk, ok = parseGenAiEvent([]byte(`{"apiFormat":"COHERE","finishReason":"COMPLETE","usage":{"promptTokens":3,"completionTokens":5,"totalTokens":8}}`), "cohere.command-r")
if !ok || *chunk.Choices[0].FinishReason != "stop" || chunk.Usage.TotalTokens != 8 {
t.Errorf("cohere 终帧解析 = %+v, %v", chunk, ok)
if sdkUsageToIR(nil) != nil {
t.Fatal("nil usage 应返回 nil")
}
}
+107
View File
@@ -0,0 +1,107 @@
package oci
import (
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/oracle/oci-go-sdk/v65/identitydomains"
)
func domainSummary(id, name, url string, state identity.DomainLifecycleStateEnum) identity.DomainSummary {
return identity.DomainSummary{Id: &id, DisplayName: &name, Url: &url, LifecycleState: state}
}
func TestPickDomainURL(t *testing.T) {
domains := []identity.DomainSummary{
domainSummary("ocid1.domain.idcs", "OracleIdentityCloudService", "https://idcs-a.example.com", identity.DomainLifecycleStateActive),
domainSummary("ocid1.domain.default", "Default", "https://idcs-b.example.com", identity.DomainLifecycleStateActive),
domainSummary("ocid1.domain.off", "Off", "https://idcs-c.example.com", identity.DomainLifecycleStateInactive),
}
cases := []struct {
name string
domains []identity.DomainSummary
domainID string
want string
}{
{"缺省优先 Default 域", domains, "", "https://idcs-b.example.com"},
{"按 OCID 精确匹配", domains, "ocid1.domain.idcs", "https://idcs-a.example.com"},
{"OCID 不存在返回空", domains, "ocid1.domain.miss", ""},
{"非 ACTIVE 域不可选", domains, "ocid1.domain.off", ""},
{"无 Default 时取第一个 ACTIVE", domains[:1], "", "https://idcs-a.example.com"},
{"空列表返回空", nil, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pickDomainURL(tc.domains, tc.domainID); got != tc.want {
t.Fatalf("pickDomainURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestToDomainListUser(t *testing.T) {
created := "2024-12-12T14:52:00Z"
lastLogin := "2026-07-01T08:00:00.123Z"
yes, no := true, false
u := identitydomains.User{
Id: common.String("scim-1"),
Ocid: common.String("ocid1.user.oc1..a"),
UserName: common.String("oci"),
Active: &yes,
Meta: &identitydomains.Meta{Created: &created},
Emails: []identitydomains.UserEmails{{
Value: common.String("a@b.c"), Primary: &yes, Verified: &yes,
Type: identitydomains.UserEmailsTypeWork,
}},
UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser: &identitydomains.ExtensionMfaUser{
MfaStatus: identitydomains.ExtensionMfaUserMfaStatusEnrolled,
},
UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser: &identitydomains.ExtensionUserStateUser{
LastSuccessfulLoginDate: &lastLogin,
},
}
got := toDomainListUser(u, "ocid1.user.oc1..a")
if got.ID != "ocid1.user.oc1..a" || !got.IsCurrentUser {
t.Fatalf("ID/IsCurrentUser 映射错误: %+v", got)
}
if !got.MfaActivated || !got.EmailVerified || got.Email != "a@b.c" {
t.Fatalf("MFA/邮箱映射错误: %+v", got)
}
if got.LifecycleState != "ACTIVE" || got.TimeCreated == nil || got.LastLoginTime == nil {
t.Fatalf("状态/时间映射错误: %+v", got)
}
inactive := identitydomains.User{Id: common.String("scim-2"), Ocid: common.String("ocid1.user.oc1..b"), Active: &no}
if s := toDomainListUser(inactive, "x").LifecycleState; s != "INACTIVE" {
t.Fatalf("inactive 用户 LifecycleState = %q, want INACTIVE", s)
}
}
func TestParseScimTime(t *testing.T) {
valid := "2026-07-10T01:02:03Z"
bad := "not-a-time"
empty := ""
cases := []struct {
name string
in *string
want *time.Time
}{
{"合法 RFC3339", &valid, func() *time.Time { t0, _ := time.Parse(time.RFC3339, valid); return &t0 }()},
{"nil 返回 nil", nil, nil},
{"空串返回 nil", &empty, nil},
{"非法格式返回 nil", &bad, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseScimTime(tc.in)
if (got == nil) != (tc.want == nil) {
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
}
if got != nil && !got.Equal(*tc.want) {
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
}
})
}
}
@@ -25,7 +25,7 @@ func TestRealOCIFederation(t *testing.T) {
client := NewClient()
region := cred.Region
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region)
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region, "")
if err != nil {
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
}
@@ -34,7 +34,7 @@ func TestRealOCIFederation(t *testing.T) {
}
t.Logf("domain saml metadata: %d bytes", len(metadata))
before, err := client.ListConsoleSignOnRules(ctx, cred, region)
before, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil {
t.Fatalf("ListConsoleSignOnRules: %v", err)
}
@@ -43,7 +43,7 @@ func TestRealOCIFederation(t *testing.T) {
idp := createTestIdp(ctx, t, client, cred, region)
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, true)
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, true)
if err != nil {
t.Fatalf("activate idp: %v", err)
}
@@ -54,7 +54,7 @@ func TestRealOCIFederation(t *testing.T) {
exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before))
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, false)
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, false)
if err != nil {
t.Fatalf("deactivate idp: %v", err)
}
@@ -71,7 +71,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
t.Fatalf("read test-idp.xml: %v", err)
}
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, CreateIdpInput{
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, "", CreateIdpInput{
Name: name,
Metadata: string(metadata),
Description: "oci-portal e2e temporary idp",
@@ -86,7 +86,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer ccancel()
if err := client.DeleteIdentityProvider(cctx, cred, region, idp.ID); err != nil {
if err := client.DeleteIdentityProvider(cctx, cred, region, "", idp.ID); err != nil {
t.Errorf("cleanup: delete idp: %v", err)
} else {
t.Log("cleanup: idp deleted")
@@ -98,7 +98,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
t.Helper()
dc, err := client.domainsClient(ctx, cred, region)
dc, err := client.domainsClient(ctx, cred, region, "")
if err != nil {
t.Fatalf("domainsClient: %v", err)
}
@@ -151,7 +151,7 @@ func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.Ide
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
t.Helper()
dc, err := client.domainsClient(ctx, cred, region)
dc, err := client.domainsClient(ctx, cred, region, "")
if err != nil {
t.Fatalf("domainsClient: %v", err)
}
@@ -175,7 +175,7 @@ func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
t.Helper()
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, idpID, "oci-portal-e2e-skip-mfa")
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, "", idpID, "oci-portal-e2e-skip-mfa")
if err != nil {
t.Fatalf("CreateMfaExemptionRule: %v", err)
}
@@ -185,12 +185,12 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
if deleted {
return
}
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
t.Errorf("cleanup: delete exemption rule: %v", err)
}
}()
after, err := client.ListConsoleSignOnRules(ctx, cred, region)
after, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil {
t.Fatalf("ListConsoleSignOnRules after create: %v", err)
}
@@ -208,11 +208,11 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
t.Errorf("condition value %q does not reference idp", after[0].ConditionValue)
}
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
t.Fatalf("DeleteMfaExemptionRule: %v", err)
}
deleted = true
restored, err := client.ListConsoleSignOnRules(ctx, cred, region)
restored, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil {
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
}
+12 -12
View File
@@ -21,7 +21,7 @@ func TestRealOCITenantUsers(t *testing.T) {
client := NewClient()
homeRegion := cred.Region
users, err := client.ListTenantUsers(ctx, cred)
users, err := client.ListTenantUsers(ctx, cred, homeRegion, "")
if err != nil {
t.Fatalf("ListTenantUsers: %v", err)
}
@@ -40,7 +40,7 @@ func TestRealOCITenantUsers(t *testing.T) {
t.Logf("tenant users: %d", len(users))
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
user, err := client.CreateTenantUser(ctx, cred, homeRegion, CreateTenantUserInput{
user, err := client.CreateTenantUser(ctx, cred, homeRegion, "", CreateTenantUserInput{
Name: name, Description: "e2e temp user", Email: name + "@example.com",
GivenName: "E2E", FamilyName: "Temp",
})
@@ -49,7 +49,7 @@ func TestRealOCITenantUsers(t *testing.T) {
}
t.Logf("created user %s (%s)", user.Name, user.ID)
defer func() {
if err := client.DeleteTenantUser(ctx, cred, homeRegion, user.ID); err != nil {
if err := client.DeleteTenantUser(ctx, cred, homeRegion, "", user.ID); err != nil {
t.Errorf("cleanup: DeleteTenantUser: %v", err)
} else {
t.Log("cleanup: user deleted")
@@ -57,7 +57,7 @@ func TestRealOCITenantUsers(t *testing.T) {
}()
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, user.ID, UpdateTenantUserInput{
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, "", user.ID, UpdateTenantUserInput{
Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
})
if err != nil {
@@ -68,7 +68,7 @@ func TestRealOCITenantUsers(t *testing.T) {
}
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, user.ID)
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, "", user.ID)
if err != nil {
t.Fatalf("ResetTenantUserPassword: %v", err)
}
@@ -77,7 +77,7 @@ func TestRealOCITenantUsers(t *testing.T) {
}
t.Logf("password reset ok (len=%d)", len(password))
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, user.ID)
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, "", user.ID)
if err != nil {
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
}
@@ -101,12 +101,12 @@ func TestRealOCIDomainSettings(t *testing.T) {
client := NewClient()
region := cred.Region
original, err := client.GetNotificationRecipients(ctx, cred, region)
original, err := client.GetNotificationRecipients(ctx, cred, region, "")
if err != nil {
t.Fatalf("GetNotificationRecipients: %v", err)
}
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, []string{"e2e@example.com"})
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, "", []string{"e2e@example.com"})
if err != nil {
t.Fatalf("UpdateNotificationRecipients: %v", err)
}
@@ -117,13 +117,13 @@ func TestRealOCIDomainSettings(t *testing.T) {
if !original.TestModeEnabled {
restoreRecipients = nil
}
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, restoreRecipients); err != nil {
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, "", restoreRecipients); err != nil {
t.Errorf("restore recipients: %v", err)
} else {
t.Log("recipients restored")
}
policies, err := client.ListPasswordPolicies(ctx, cred, region)
policies, err := client.ListPasswordPolicies(ctx, cred, region, "")
if err != nil {
t.Fatalf("ListPasswordPolicies: %v", err)
}
@@ -133,7 +133,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
}
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
days := 350
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
if err != nil {
t.Fatalf("UpdatePasswordPolicy: %v", err)
}
@@ -144,7 +144,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
if target.PasswordExpiresAfter != nil {
restore = *target.PasswordExpiresAfter
}
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
t.Errorf("restore password policy: %v", err)
} else {
t.Logf("policy restored to %d", restore)
+6 -6
View File
@@ -29,8 +29,8 @@ type SignOnRuleInfo struct {
}
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return nil, err
}
@@ -99,8 +99,8 @@ func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsCl
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region)
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return SignOnRuleInfo{}, err
}
@@ -239,11 +239,11 @@ func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsCli
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
// 拒绝删除 Oracle 预置规则。
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error {
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error {
if strings.HasPrefix(ruleID, "OciConsole") {
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
}
dc, err := c.domainsClient(ctx, cred, region)
dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil {
return err
}
+147 -28
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"math/big"
"slices"
"strings"
"time"
@@ -28,11 +29,18 @@ type TenantUser struct {
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
}
// Oracle 预置的管理员角色与管理员组显示名。
const (
domainAdminRoleName = "Identity Domain Administrator"
administratorsGroupName = "Administrators"
)
// Oracle 预置的「身份域管理员」应用角色显示名。
const domainAdminRoleName = "Identity Domain Administrator"
// adminGroupNames 是租户管理员组候选名(按优先序):原生域为 Administrators,
// IDCS 迁移域(idcs foundation,如 OracleIdentityCloudService)为 OCI_Administrators;
// 迁移域另有 IDCS_Administrators 属身份域管理范畴,由上面的应用角色概念覆盖,不算在内。
var adminGroupNames = []string{"Administrators", "OCI_Administrators"}
// isAdminGroupName 判断组显示名是否为租户管理员组。
func isAdminGroupName(name string) bool {
return slices.Contains(adminGroupNames, name)
}
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
@@ -48,8 +56,8 @@ type TenantUserDetail struct {
}
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion)
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return TenantUserDetail{}, err
}
@@ -68,7 +76,7 @@ func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
d.FamilyName = deref(u.Name.FamilyName)
}
for _, g := range u.Groups {
if deref(g.Display) == administratorsGroupName {
if isAdminGroupName(deref(g.Display)) {
d.InAdminGroup = true
}
}
@@ -94,8 +102,13 @@ func (c *RealClient) identityClientAt(cred Credentials, region string) (identity
return ic, nil
}
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) {
// ListTenantUsers 实现 Client:列出租户 IAM 用户。
// domainID 非空时经 Identity Domains SCIM 只列该域用户;
// 为空时走经典 IAM(跨域拍平视图,兼容无域老租户)。
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
if domainID != "" {
return c.listDomainUsers(ctx, cred, homeRegion, domainID)
}
ic, err := c.identityClientAt(cred, "")
if err != nil {
return nil, err
@@ -117,6 +130,78 @@ func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]T
}
}
// scimListAttrs 是按域列用户时请求的属性集:列表字段 + MFA 与最近登录扩展。
const scimListAttrs = "id,ocid,userName,description,emails,active,meta.created," +
"urn:ietf:params:scim:schemas:oracle:idcs:extension:mfa:User:mfaStatus," +
"urn:ietf:params:scim:schemas:oracle:idcs:extension:userState:User:lastSuccessfulLoginDate"
// maxScimUserPages 限制按域列用户的翻页数(每页 200,上限 5000 人)。
const maxScimUserPages = 25
// listDomainUsers 经 SCIM 按 startIndex 翻页列出域内全部用户。
func (c *RealClient) listDomainUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return nil, err
}
var users []TenantUser
attrs := scimListAttrs
count := 200
start := 1
for range maxScimUserPages {
req := identitydomains.ListUsersRequest{Attributes: &attrs, Count: &count, StartIndex: &start}
resp, err := dc.ListUsers(ctx, req)
if err != nil {
return nil, fmt.Errorf("list domain users: %w", err)
}
for _, u := range resp.Resources {
users = append(users, toDomainListUser(u, cred.UserOCID))
}
start += len(resp.Resources)
if resp.TotalResults == nil || start > *resp.TotalResults || len(resp.Resources) == 0 {
return orEmpty(users), nil
}
}
return orEmpty(users), nil
}
// toDomainListUser 把 SCIM 用户映射为列表 DTO;ID 用 IAM OCID
// 保持与经典路径同一 id 语义(IsCurrentUser 比对、后续操作路由)。
func toDomainListUser(u identitydomains.User, currentUserOCID string) TenantUser {
out := toDomainTenantUser(u)
out.IsCurrentUser = out.ID == currentUserOCID
if u.Active != nil && !*u.Active {
out.LifecycleState = "INACTIVE"
}
if u.Meta != nil {
out.TimeCreated = parseScimTime(u.Meta.Created)
}
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser; ext != nil {
out.MfaActivated = ext.MfaStatus == identitydomains.ExtensionMfaUserMfaStatusEnrolled
}
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser; ext != nil {
out.LastLoginTime = parseScimTime(ext.LastSuccessfulLoginDate)
}
for _, e := range u.Emails {
if e.Primary != nil && *e.Primary {
out.EmailVerified = e.Verified != nil && *e.Verified
}
}
return out
}
// parseScimTime 解析 SCIM 的 RFC3339 时间串;空值或格式异常返回 nil。
func parseScimTime(s *string) *time.Time {
if s == nil || *s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, *s)
if err != nil {
return nil
}
return &t
}
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
// 先授身份域管理员角色,再加入 Administrators 组。
@@ -133,12 +218,13 @@ type CreateTenantUserInput struct {
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in)
// domainID 非空表示用户显式选择了目标域,SCIM 失败不再回退(避免建错域)。
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
user, err := c.createUserViaDomain(ctx, cred, homeRegion, domainID, in)
if err == nil {
return user, nil
}
if in.GrantDomainAdmin || in.AddToAdminGroup {
if domainID != "" || in.GrantDomainAdmin || in.AddToAdminGroup {
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
}
return c.createUserClassic(ctx, cred, homeRegion, in)
@@ -170,8 +256,8 @@ func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, ho
}
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion)
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return TenantUser{}, err
}
@@ -373,9 +459,9 @@ type UpdateTenantUserInput struct {
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
// IAM API(仅备注与邮箱,姓名变更报错)。
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion)
// IAM API(仅备注与邮箱,姓名变更报错);domainID 非空时不回退
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return TenantUser{}, err
}
@@ -394,6 +480,9 @@ func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, hom
}
return user, nil
}
if domainID != "" {
return TenantUser{}, fmt.Errorf("update user %s: user not found in identity domain", userID)
}
if in.GivenName != nil || in.FamilyName != nil {
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
}
@@ -495,7 +584,11 @@ func emailPatchOp(u identitydomains.User, email string) identitydomains.Operatio
}
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error {
// domainID 非空时经该域 SCIM 删除(经典 API 对非默认域用户不生效)。
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
if domainID != "" {
return c.deleteDomainUser(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return err
@@ -506,9 +599,31 @@ func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, hom
return nil
}
// deleteDomainUser 经 SCIM 强制删除域用户(forceDelete 连带其授权与组成员关系)。
func (c *RealClient) deleteDomainUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return err
}
scimID, err := scimUserID(ctx, dc, userID)
if err != nil {
return err
}
force := true
_, err = dc.DeleteUser(ctx, identitydomains.DeleteUserRequest{UserId: &scimID, ForceDelete: &force})
if err != nil {
return fmt.Errorf("delete domain user %s: %w", userID, err)
}
return nil
}
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
// domainID 非空直走该域 SCIM;为空优先经典 IAM API,
// 新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
if domainID != "" {
return c.resetPasswordViaDomain(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return "", err
@@ -520,12 +635,12 @@ func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentia
if !isClassicUnsupported(err) {
return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
}
return c.resetPasswordViaDomain(ctx, cred, homeRegion, userID)
return c.resetPasswordViaDomain(ctx, cred, homeRegion, "", userID)
}
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion)
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return "", err
}
@@ -558,7 +673,11 @@ func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credential
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) {
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error) {
// 显式指定域时用户的认证因子只在域内,经典 TOTP 清理不适用
if domainID != "" {
return 0, c.removeDomainFactors(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion)
if err != nil {
return 0, err
@@ -567,7 +686,7 @@ func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Creden
if err != nil {
return deleted, err
}
if err := c.removeDomainFactors(ctx, cred, homeRegion, userID); err != nil {
if err := c.removeDomainFactors(ctx, cred, homeRegion, "", userID); err != nil {
return deleted, err
}
return deleted, nil
@@ -594,8 +713,8 @@ func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, u
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error {
dc, err := c.domainsClient(ctx, cred, homeRegion)
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return err
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestToTenantUserDetail(t *testing.T) {
},
Groups: []identitydomains.UserGroups{
{Value: common.String("g1"), Display: common.String("Readers")},
{Value: common.String("g2"), Display: common.String(administratorsGroupName)},
{Value: common.String("g2"), Display: common.String(adminGroupNames[0])},
},
UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles,
},
-332
View File
@@ -1,332 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。
// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。
func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) {
ir := aiwire.ChatRequest{
Model: req.Model,
Temperature: req.Temperature,
TopP: req.TopP,
TopK: req.TopK,
Stop: req.StopSequences,
Stream: req.Stream,
Tools: anthTools(req.Tools),
ToolChoice: anthToolChoice(req.ToolChoice),
}
if req.MaxTokens > 0 {
mt := req.MaxTokens
ir.MaxTokens = &mt
}
if sys := req.SystemText(); sys != "" {
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)})
}
for _, m := range req.Messages {
msgs, err := anthMessageToIR(m)
if err != nil {
return ir, err
}
ir.Messages = append(ir.Messages, msgs...)
}
return ir, nil
}
// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息,
// image 块转为 IR image_url 部件。
func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) {
var out []aiwire.ChatMessage
var parts []aiwire.ContentPart
var toolCalls []aiwire.ToolCall
hasImage := false
for _, b := range m.Content.AllBlocks() {
switch b.Type {
case "text":
parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text})
case "image":
p, err := anthImagePart(b.Source)
if err != nil {
return nil, err
}
parts, hasImage = append(parts, p), true
case "tool_use":
toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function",
Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}})
case "tool_result":
out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID,
Content: aiwire.NewTextContent(b.ResultText())})
case "thinking", "redacted_thinking":
// 一期忽略 thinking 块
default:
return nil, ErrAiUnsupportedBlock
}
}
content := partsContent(parts, hasImage)
if hasImage || content.JoinText() != "" || len(toolCalls) > 0 {
out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls})
}
return out, nil
}
// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。
func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) {
var src struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
URL string `json:"url"`
}
if err := json.Unmarshal(source, &src); err != nil {
return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err)
}
switch src.Type {
case "base64":
if src.MediaType == "" || src.Data == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data")
}
url := "data:" + src.MediaType + ";base64," + src.Data
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil
case "url":
if src.URL == "" {
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url")
}
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil
}
return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
}
// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。
func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content {
if !hasImage {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p.Text)
}
return aiwire.NewTextContent(sb.String())
}
return aiwire.NewPartsContent(parts)
}
func anthTools(tools []aiwire.AnthTool) []aiwire.Tool {
if len(tools) == 0 {
return nil
}
out := make([]aiwire.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, aiwire.Tool{Type: "function", Function: aiwire.FunctionDef{
Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
}
return out
}
// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。
func anthToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &tc) != nil {
return nil
}
switch tc.Type {
case "auto":
return json.RawMessage(`"auto"`)
case "any":
return json.RawMessage(`"required"`)
case "tool":
b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}})
return b
case "none":
return json.RawMessage(`"none"`)
}
return nil
}
// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。
func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse {
out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if text := choice.Message.Content.JoinText(); text != "" {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text})
}
for _, tc := range choice.Message.ToolCalls {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID,
Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)})
}
out.StopReason = anthStopReason(choice.FinishReason)
}
if resp.Usage != nil {
out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens,
CacheReadInputTokens: resp.Usage.CachedTokens()}
}
return out
}
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
func argsToJSON(args string) json.RawMessage {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(map[string]string{"_raw": args})
return b
}
func anthStopReason(finish string) string {
switch finish {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
default:
return "end_turn"
}
}
// ---- Anthropic 流式状态机 ----
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
type AnthEvent struct {
Event string
Data any
}
// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列:
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
type AnthStream struct {
id, model string
started bool
blockOpen bool
blockIsTool bool
toolID string
blockIndex int
stopReason string
usage aiwire.AnthUsage
}
// NewAnthStream 构造状态机;id 为响应消息 ID。
func NewAnthStream(id, model string) *AnthStream {
return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
}
// Feed 消费一个 IR chunk,返回应立即写出的事件。
func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent {
var events []AnthEvent
if !st.started {
st.started = true
events = append(events, st.startEvent())
}
if chunk.Usage != nil {
st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens,
CacheReadInputTokens: chunk.Usage.CachedTokens()}
}
for _, choice := range chunk.Choices {
events = append(events, st.feedDelta(choice.Delta)...)
if choice.FinishReason != nil && *choice.FinishReason != "" {
st.stopReason = anthStopReason(*choice.FinishReason)
}
}
return events
}
func (st *AnthStream) startEvent() AnthEvent {
return AnthEvent{Event: "message_start", Data: map[string]any{
"type": "message_start",
"message": map[string]any{
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
"content": []any{}, "stop_reason": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
},
}}
}
// feedDelta 处理文本与工具调用增量,必要时切块。
func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent {
var events []AnthEvent
if d.Content != "" {
if !st.blockOpen || st.blockIsTool {
events = append(events, st.openBlock(false, "", "")...)
}
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "text_delta", "text": d.Content},
}})
}
for _, tc := range d.ToolCalls {
if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) {
events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...)
}
if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool {
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments},
}})
}
}
return events
}
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
var events []AnthEvent
if st.blockOpen {
events = append(events, st.closeBlockEvent())
}
st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID
st.blockIndex++
block := map[string]any{"type": "text", "text": ""}
if isTool {
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
}
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
}})
return events
}
func (st *AnthStream) closeBlockEvent() AnthEvent {
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
"type": "content_block_stop", "index": st.blockIndex,
}}
}
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
func (st *AnthStream) Finish() []AnthEvent {
var events []AnthEvent
if !st.started {
events = append(events, st.startEvent())
st.started = true
}
if st.blockOpen {
events = append(events, st.closeBlockEvent())
st.blockOpen = false
}
events = append(events,
AnthEvent{Event: "message_delta", Data: map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
"usage": map[string]int{"output_tokens": st.usage.OutputTokens},
}},
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
)
return events
}
// Usage 返回聚合到的用量(供调用日志)。
func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage }
-136
View File
@@ -1,136 +0,0 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func mustAnthReq(t *testing.T, body string) aiwire.MessagesRequest {
t.Helper()
var req aiwire.MessagesRequest
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("unmarshal anthropic request: %v", err)
}
return req
}
func TestAnthropicToIR(t *testing.T) {
req := mustAnthReq(t, `{
"model": "meta.llama-3.3-70b-instruct", "max_tokens": 128, "system": "你是助手",
"messages": [
{"role": "user", "content": "东京天气?"},
{"role": "assistant", "content": [
{"type": "text", "text": "查询中"},
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
{"type": "text", "text": "继续"}
]}
],
"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "any"}
}`)
ir, err := AnthropicToIR(req)
if err != nil {
t.Fatalf("AnthropicToIR: %v", err)
}
roles := make([]string, 0, len(ir.Messages))
for _, m := range ir.Messages {
roles = append(roles, m.Role)
}
want := []string{"system", "user", "assistant", "tool", "user"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Fatalf("roles = %v, want %v", roles, want)
}
if ir.Messages[2].ToolCalls[0].Function.Name != "get_weather" {
t.Errorf("tool_use 未映射: %+v", ir.Messages[2])
}
if ir.Messages[3].ToolCallID != "t1" || ir.Messages[3].Content.JoinText() != "晴 25 度" {
t.Errorf("tool_result 未拆为 tool 消息: %+v", ir.Messages[3])
}
if ir.MaxTokens == nil || *ir.MaxTokens != 128 || len(ir.Tools) != 1 {
t.Errorf("max_tokens/tools 未直通")
}
if string(ir.ToolChoice) != `"required"` {
t.Errorf("tool_choice any → %s, want required", ir.ToolChoice)
}
// image 块拒绝
bad := mustAnthReq(t, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"image","source":{}}]}]}`)
if _, err := AnthropicToIR(bad); err == nil {
t.Error("image 块应被拒绝")
}
}
func TestIRRespToAnthropic(t *testing.T) {
resp := &aiwire.ChatResponse{
Model: "m1",
Choices: []aiwire.Choice{{
Message: aiwire.ChatMessage{
Role: "assistant",
Content: aiwire.NewTextContent("查到了"),
ToolCalls: []aiwire.ToolCall{{ID: "t1", Type: "function",
Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}},
},
FinishReason: "tool_calls",
}},
Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5},
}
out := IRRespToAnthropic(resp, "msg_1")
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[1].Type != "tool_use" {
t.Fatalf("content 块 = %+v", out.Content)
}
if out.StopReason != "tool_use" || out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 {
t.Errorf("stop_reason/usage 未映射: %+v", out)
}
if string(out.Content[1].Input) != `{"city":"东京"}` {
t.Errorf("tool input = %s", out.Content[1].Input)
}
}
// eventTypes 提取事件类型序列便于断言。
func eventTypes(events []AnthEvent) string {
types := make([]string, 0, len(events))
for _, e := range events {
types = append(types, e.Event)
}
return strings.Join(types, ",")
}
func TestAnthStreamTextAndTool(t *testing.T) {
st := NewAnthStream("msg_1", "m1")
fr := "tool_calls"
var events []AnthEvent
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "你"}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "好"}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Name: "get_weather", Arguments: `{"ci`}}},
}}}})...)
events = append(events, st.Feed(aiwire.ChatChunk{
Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Arguments: `ty":"东京"}`}}}}, FinishReason: &fr}},
Usage: &aiwire.Usage{PromptTokens: 8, CompletionTokens: 4},
})...)
events = append(events, st.Finish()...)
got := eventTypes(events)
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
"content_block_stop,content_block_start,content_block_delta,content_block_delta," +
"content_block_stop,message_delta,message_stop"
if got != want {
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
}
if st.Usage().OutputTokens != 4 {
t.Errorf("usage 未聚合: %+v", st.Usage())
}
}
func TestAnthStreamEmpty(t *testing.T) {
st := NewAnthStream("msg_1", "m1")
events := st.Finish()
if got := eventTypes(events); got != "message_start,message_delta,message_stop" {
t.Errorf("空流事件序列 = %s", got)
}
}
+225 -17
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
@@ -14,6 +15,7 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
@@ -78,7 +80,7 @@ func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
// group 非空时该密钥只在同分组渠道内路由。
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) {
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string, models []string) (string, *model.AiKey, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", nil, fmt.Errorf("密钥名称不能为空")
@@ -94,13 +96,28 @@ func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, gro
if len(raw) < 8 {
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
}
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true}
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Models: normalizeKeyModels(models), Enabled: true}
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
}
return raw, key, nil
}
// normalizeKeyModels 规范化模型白名单:trim、剔空串、保序去重;结果为空返回 nil(= 不限)。
func normalizeKeyModels(models []string) []string {
var out []string
seen := map[string]bool{}
for _, m := range models {
m = strings.TrimSpace(m)
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
func hashKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
@@ -113,8 +130,8 @@ func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
return keys, err
}
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
// UpdateKey 修改密钥名称 / 启用状态 / 分组 / 模型白名单(指针非空即覆盖,可置空)。
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string, models *[]string) error {
updates := map[string]any{}
if name = strings.TrimSpace(name); name != "" {
updates["name"] = name
@@ -125,6 +142,14 @@ func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string,
if group != nil {
updates["key_group"] = strings.TrimSpace(*group)
}
if models != nil {
// 手动序列化走 map 更新,不依赖 GORM map 路径对 serializer 的支持
b, err := json.Marshal(normalizeKeyModels(*models))
if err != nil {
return fmt.Errorf("serialize models: %w", err)
}
updates["models"] = string(b)
}
if len(updates) == 0 {
return nil
}
@@ -289,12 +314,17 @@ func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.Ai
return &fresh, nil
}
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步(黑名单模型不入库)
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
if err != nil {
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
}
models = supportedGatewayModels(models)
models, err = s.withoutBlacklisted(ctx, models)
if err != nil {
return "error", truncateErr(err.Error())
}
if len(models) == 0 {
_ = s.replaceModels(ctx, ch.ID, nil)
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
@@ -305,40 +335,88 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
return s.probeChat(ctx, cred, ch, models)
}
// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际
// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
status, detail := "error", "无可试调对话模型"
errBudget := 3
for _, m := range probeCandidates(models) {
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
switch {
case code == 200 || code == 429:
return "ok", ""
case oci.IsModelUnavailable(err):
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
case code == 401 || code == 403 || code == 404:
return "no_quota", truncateErr(oci.CompactError(err))
default:
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
if errBudget--; errBudget == 0 {
return status, detail
}
}
}
return status, detail
}
// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先,
// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)
// probeCandidateCap 是单次探测的候选上限:放宽到 8 让一次探测有机会越过整批
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算
const probeCandidateCap = 8
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
var sorted []oci.GenAiModel
for _, m := range models {
if m.Capability == "" || m.Capability == "CHAT" {
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
sorted = append(sorted, m)
}
}
sort.SliceStable(sorted, func(i, j int) bool {
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
})
if len(sorted) > 3 {
sorted = sorted[:3]
return diversifyByVendor(sorted, probeCandidateCap)
}
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
func diversifyByVendor(sorted []oci.GenAiModel, limit int) []oci.GenAiModel {
picked := make([]oci.GenAiModel, 0, limit)
used := make(map[int]bool)
seenVendor := make(map[string]bool)
for i, m := range sorted {
if len(picked) >= limit {
break
}
if v := modelVendor(m); !seenVendor[v] {
seenVendor[v] = true
used[i] = true
picked = append(picked, m)
}
}
return sorted
for i, m := range sorted {
if len(picked) >= limit {
break
}
if !used[i] {
picked = append(picked, m)
}
}
return picked
}
// modelVendor 取厂商标识;OCI 未回填 vendor 时退化为模型名「.」前缀。
func modelVendor(m oci.GenAiModel) string {
if m.Vendor != "" {
return strings.ToLower(m.Vendor)
}
name := strings.ToLower(m.Name)
if i := strings.Index(name, "."); i > 0 {
return name[:i]
}
return name
}
func probeScore(name string) int {
@@ -376,7 +454,7 @@ func truncateErr(msg string) string {
return msg
}
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存,黑名单中的模型不入库
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
@@ -390,6 +468,10 @@ func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiM
if err != nil {
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
}
models = supportedGatewayModels(models)
if models, err = s.withoutBlacklisted(ctx, models); err != nil {
return nil, err
}
if err := s.replaceModels(ctx, id, models); err != nil {
return nil, err
}
@@ -421,7 +503,7 @@ func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([
return rows, err
}
// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models;
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
q := s.db.WithContext(ctx).
@@ -501,18 +583,134 @@ func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
return msg, nil
}
// ---- 模型黑名单 ----
// Blacklist 列出全部黑名单模型(按名称排序)。
func (s *AiGatewayService) Blacklist(ctx context.Context) ([]model.AiModelBlacklist, error) {
var rows []model.AiModelBlacklist
err := s.db.WithContext(ctx).Order("name ASC").Find(&rows).Error
return rows, err
}
// AddBlacklist 把模型名加入黑名单并删除全部渠道缓存中的同名条目;
// 该模型此后同步 / 探测均被过滤,直到移出黑名单后重新同步。
func (s *AiGatewayService) AddBlacklist(ctx context.Context, name string) (*model.AiModelBlacklist, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("模型名不能为空")
}
row := model.AiModelBlacklist{Name: name}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var n int64
if err := tx.Model(&model.AiModelBlacklist{}).Where("name = ?", name).Count(&n).Error; err != nil {
return err
}
if n > 0 {
return fmt.Errorf("模型已在黑名单中")
}
if err := tx.Create(&row).Error; err != nil {
return err
}
return tx.Where("name = ?", name).Delete(&model.AiModelCache{}).Error
})
if err != nil {
return nil, err
}
return &row, nil
}
// RemoveBlacklist 把模型移出黑名单;缓存不回填,下次同步 / 探测自然恢复入池。
func (s *AiGatewayService) RemoveBlacklist(ctx context.Context, id uint) error {
res := s.db.WithContext(ctx).Delete(&model.AiModelBlacklist{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("黑名单条目不存在")
}
return nil
}
// withoutBlacklisted 过滤掉黑名单中的模型,同步与探测入库前统一经此收口。
// supportedGatewayModels 过滤模型目录:对话模型仅保留实测支持 OpenAI 兼容面的
// 厂商(xai / meta / openai)——typed chat 面已剔除,google / cohere 对话模型无
// 上游通路,不入目录(不出现在列表、路由与探测候选);EMBEDDING 模型不受影响。
func supportedGatewayModels(models []oci.GenAiModel) []oci.GenAiModel {
out := make([]oci.GenAiModel, 0, len(models))
for _, m := range models {
if m.Capability == "CHAT" && !compatChatVendor(m.Name) {
continue
}
out = append(out, m)
}
return out
}
func compatChatVendor(model string) bool {
for _, prefix := range []string{"xai.", "meta.", "openai."} {
if strings.HasPrefix(model, prefix) {
return true
}
}
return false
}
func (s *AiGatewayService) withoutBlacklisted(ctx context.Context, models []oci.GenAiModel) ([]oci.GenAiModel, error) {
var names []string
if err := s.db.WithContext(ctx).Model(&model.AiModelBlacklist{}).Pluck("name", &names).Error; err != nil {
return nil, fmt.Errorf("读取模型黑名单: %w", err)
}
if len(names) == 0 {
return models, nil
}
banned := make(map[string]bool, len(names))
for _, n := range names {
banned[n] = true
}
out := make([]oci.GenAiModel, 0, len(models))
for _, m := range models {
if !banned[m.Name] {
out = append(out, m)
}
}
return out, nil
}
// ---- 调用日志 ----
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
entry.ErrMsg = truncateErr(entry.ErrMsg)
if err := s.db.Create(&entry).Error; err != nil {
stored := false
err := s.db.Transaction(func(tx *gorm.DB) error {
ok, err := lockAiLogParent(tx, &model.AiChannel{}, entry.ChannelID)
if err != nil || !ok {
return err
}
stored = true
return tx.Create(&entry).Error
})
if err != nil {
log.Printf("ai call log: %v", err)
return 0
}
if !stored {
return 0
}
return entry.ID
}
func lockAiLogParent(tx *gorm.DB, value any, id uint) (bool, error) {
if id == 0 {
return true, nil
}
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(value, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return err == nil, err
}
// CallLogs 分页查询调用日志。
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
if page < 1 {
@@ -555,9 +753,19 @@ func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hou
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
if entry.CallLogID == 0 {
return
}
entry.RequestBody = truncateBody(entry.RequestBody)
entry.ResponseBody = truncateBody(entry.ResponseBody)
if err := s.db.Create(&entry).Error; err != nil {
err := s.db.Transaction(func(tx *gorm.DB) error {
ok, err := lockAiLogParent(tx, &model.AiCallLog{}, entry.CallLogID)
if err != nil || !ok {
return err
}
return tx.Create(&entry).Error
})
if err != nil {
log.Printf("ai content log: %v", err)
}
}
+40 -24
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"io"
"math/rand"
"time"
@@ -26,27 +27,31 @@ type aiCandidate struct {
modelOcid string
}
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。
// group 非空时只在同分组渠道内路由(取自调用密钥)
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) {
// RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 →
// 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
resp, err := s.callOnce(ctx, cand, ir)
payload, err := s.passthroughOnce(ctx, cand, raw)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
return payload, meta, nil
}
if !retryable(err) {
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
if penalize {
s.markFailure(ctx, cand.ch.ID)
}
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
@@ -54,22 +59,22 @@ func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, grou
return nil, meta, lastErr
}
func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidate, raw []byte) ([]byte, error) {
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, err
}
return s.client.GenAiChat(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw)
}
// OpenStream 编排流式调用:流建立成功即绑定渠道,建立失败可换渠道重试。
// group 语义与 Chat 相同
func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) {
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
@@ -78,15 +83,18 @@ func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest
if err != nil {
return nil, meta, err
}
stream, err := s.client.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return stream, meta, nil
}
if !retryable(err) {
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
if penalize {
s.markFailure(ctx, cand.ch.ID)
}
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
@@ -102,12 +110,17 @@ func firstErr(lastErr, pickErr error) error {
return pickErr
}
// retryable 判是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。
func retryable(err error) bool {
if status, ok := oci.ServiceStatus(err); ok {
return status == 429 || status >= 500
// switchable 判断调用失败是否换渠道重试、是否计入熔断:模型级不可用(微调基座
// 400 / 实体不存在 404)换渠道但不计熔断——这是模型×区域供给问题而非渠道健康
// 问题,持久解决靠加入模型黑名单;429 / 5xx / 网络错误换渠道并计熔断;其余 4xx 直接透传。
func switchable(err error) (retry, penalize bool) {
if oci.IsModelUnavailable(err) {
return true, false
}
return true
if status, ok := oci.ServiceStatus(err); ok {
return status == 429 || status >= 500, true
}
return true, true
}
// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
@@ -221,10 +234,13 @@ func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.Embeddings
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
}
if !retryable(err) {
retry, penalize := switchable(err)
if !retry {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
if penalize {
s.markFailure(ctx, cand.ch.ID)
}
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"strings"
"testing"
"oci-portal/internal/oci"
)
// TestSupportedGatewayModels 断言 CHAT 目录仅保留兼容面厂商,EMBEDDING 不受影响。
func TestSupportedGatewayModels(t *testing.T) {
in := []oci.GenAiModel{
{Name: "xai.grok-4.3", Capability: "CHAT"},
{Name: "meta.llama-4-maverick-17b-128e", Capability: "CHAT"},
{Name: "openai.gpt-oss-120b", Capability: "CHAT"},
{Name: "google.gemini-2.5-flash", Capability: "CHAT"},
{Name: "cohere.command-a-03-2025", Capability: "CHAT"},
{Name: "cohere.embed-v4.0", Capability: "EMBEDDING"},
}
out := supportedGatewayModels(in)
names := make([]string, 0, len(out))
for _, m := range out {
names = append(names, m.Name)
}
want := "xai.grok-4.3,meta.llama-4-maverick-17b-128e,openai.gpt-oss-120b,cohere.embed-v4.0"
if got := strings.Join(names, ","); got != want {
t.Fatalf("过滤结果 = %s, want %s", got, want)
}
}
+424 -79
View File
@@ -1,8 +1,11 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
@@ -13,14 +16,28 @@ import (
)
// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
type stubServiceError struct{ status int }
type stubServiceError struct {
status int
msg string
}
func (e stubServiceError) Error() string { return "stub service error" }
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
func (e stubServiceError) GetMessage() string { return "stub" }
func (e stubServiceError) Error() string { return "stub service error" }
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
func (e stubServiceError) GetMessage() string {
if e.msg != "" {
return e.msg
}
return "stub"
}
func (e stubServiceError) GetCode() string { return "Stub" }
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }
// finetuneBaseErr 模拟「微调基座模型不可按需调用」的 OCI 400。
func finetuneBaseErr() stubServiceError {
return stubServiceError{status: 400,
msg: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
}
// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
type gatewayStubClient struct {
*fakeClient
@@ -29,14 +46,47 @@ type gatewayStubClient struct {
modelsErr error
probeCode int
probeErr error
chatResp *aiwire.ChatResponse
chatErrs []error
chatCalls int
regions []string
// probeSeq 非空时逐次弹出,模拟按候选依次试调;弹尽后回落 probeCode/probeErr
probeSeq []probeResult
// probedNames 记录试调过的模型名,供断言候选过滤
probedNames []string
chatCalls int
regions []string
embedVecs [][]float32
embedUsage *aiwire.Usage
embedErr error
passPayload []byte
passErrs []error
passCalls int
passRegions []string
}
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
f.passCalls++
f.passRegions = append(f.passRegions, region)
if len(f.passErrs) > 0 {
err := f.passErrs[0]
f.passErrs = f.passErrs[1:]
if err != nil {
return nil, err
}
}
return f.passPayload, nil
}
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte) (io.ReadCloser, error) {
f.passCalls++
f.passRegions = append(f.passRegions, region)
if len(f.passErrs) > 0 {
err := f.passErrs[0]
f.passErrs = f.passErrs[1:]
if err != nil {
return nil, err
}
}
return io.NopCloser(bytes.NewReader(f.passPayload)), nil
}
func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
@@ -47,27 +97,26 @@ func (f *gatewayStubClient) ListGenAiModels(ctx context.Context, cred oci.Creden
return f.models, f.modelsErr
}
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
return f.probeCode, f.probeErr
// probeResult 是 gatewayStubClient.probeSeq 的单次探测结果。
type probeResult struct {
code int
err error
}
func (f *gatewayStubClient) GenAiChat(ctx context.Context, cred oci.Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
f.chatCalls++
f.regions = append(f.regions, region)
if len(f.chatErrs) > 0 {
err := f.chatErrs[0]
f.chatErrs = f.chatErrs[1:]
if err != nil {
return nil, err
}
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
f.probedNames = append(f.probedNames, modelName)
if len(f.probeSeq) > 0 {
r := f.probeSeq[0]
f.probeSeq = f.probeSeq[1:]
return r.code, r.err
}
return f.chatResp, nil
return f.probeCode, f.probeErr
}
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
t.Helper()
svc := newTestService(t, client)
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
t.Fatalf("auto migrate ai tables: %v", err)
}
return NewAiGatewayService(svc.db, svc, client), svc
@@ -77,14 +126,14 @@ func TestAiKeyLifecycle(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "")
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "", nil)
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
}
if _, _, err := gw.CreateKey(ctx, "short", "abc", ""); err == nil {
if _, _, err := gw.CreateKey(ctx, "short", "abc", "", nil); err == nil {
t.Error("过短自定义密钥应被拒绝")
}
auto, _, err := gw.CreateKey(ctx, "auto", "", "")
auto, _, err := gw.CreateKey(ctx, "auto", "", "", nil)
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
}
@@ -96,7 +145,7 @@ func TestAiKeyLifecycle(t *testing.T) {
t.Errorf("错误密钥 err = %v", err)
}
off := false
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil)
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil, nil)
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
t.Errorf("禁用后 VerifyKey err = %v", err)
}
@@ -159,51 +208,6 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
return ch
}
func TestAiChatRetrySwitchesChannel(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
chatErrs: []error{stubServiceError{status: 429}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
// 两个同优先级渠道
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
ctx := context.Background()
resp, meta, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "")
if err != nil || resp == nil {
t.Fatalf("Chat = %v, %v", resp, err)
}
if meta.Retries != 1 || client.chatCalls != 2 {
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls)
}
if len(client.regions) != 2 && client.regions[0] == client.regions[1] {
t.Errorf("重试未换渠道: %v", client.regions)
}
// 未知模型
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "no-such-model"}, ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("未知模型 err = %v", err)
}
}
func TestAiChatNonRetryablePassThrough(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatErrs: []error{stubServiceError{status: 400}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
_, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, "")
if err == nil || meta.Retries != 0 || client.chatCalls != 1 {
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.chatCalls)
}
}
func TestPickPriorityAndBreaker(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
@@ -254,8 +258,8 @@ func TestMarkFailureBackoff(t *testing.T) {
func TestAiGroupRouting(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
@@ -264,17 +268,17 @@ func TestAiGroupRouting(t *testing.T) {
gw.db.Model(vip).Update("channel_group", "vip")
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
ctx := context.Background()
req := aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}
body := []byte(`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`)
// 分组密钥只落同分组渠道
for i := 0; i < 5; i++ {
_, meta, err := gw.Chat(ctx, req, "vip")
_, meta, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "vip")
if err != nil || meta.ChannelID != vip.ID {
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
}
}
// 分组内无渠道 → 无可用渠道
if _, _, err := gw.Chat(ctx, req, "nope"); !errors.Is(err, ErrAiNoChannel) {
if _, _, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "nope"); !errors.Is(err, ErrAiNoChannel) {
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
}
// 模型列表按分组过滤
@@ -287,12 +291,12 @@ func TestAiGroupRouting(t *testing.T) {
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
}
// 密钥分组落库
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip")
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip", nil)
if err != nil || key.Group != "vip" {
t.Fatalf("CreateKey group = %+v, %v", key, err)
}
empty := ""
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty)
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty, nil)
var fresh model.AiKey
gw.db.First(&fresh, key.ID)
if fresh.Group != "" {
@@ -400,6 +404,218 @@ func TestProbeCandidates(t *testing.T) {
}
}
func TestProbeCandidatesVendorDiversity(t *testing.T) {
// 部分区域单一厂商全为微调基座:候选须跨厂商分散,不能被 3 个 llama 占满前排
models := []oci.GenAiModel{
{Ocid: "l1", Name: "meta.llama-3-70b-instruct"},
{Ocid: "l2", Name: "meta.llama-3.1-405b-instruct"},
{Ocid: "l3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
{Ocid: "g1", Name: "xai.grok-4"},
}
got := probeCandidates(models)
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
}
vendors := map[string]bool{}
for _, m := range got[:3] {
vendors[modelVendor(m)] = true
}
if len(vendors) != 3 {
t.Errorf("前 3 个候选应覆盖 3 个厂商: %+v", got)
}
// 超过上限时截断到 probeCandidateCap
var many []oci.GenAiModel
for i := 0; i < 12; i++ {
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
}
if capped := probeCandidates(many); len(capped) != probeCandidateCap {
t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
}
}
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
func entityNotFoundErr() stubServiceError {
return stubServiceError{status: 404,
msg: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
}
func TestProbeSkipsUnavailableModels(t *testing.T) {
// 首选 llama 不可按需调用(基座 400 / 实体 404)→ 换候选成功 → 渠道判可用;
// 模型全量入库不再自动标记,持久剔除交由用户手动拉黑
tests := []struct {
name string
bad probeResult
}{
{"微调基座 400", probeResult{400, finetuneBaseErr()}},
{"实体不存在 404", probeResult{404, entityNotFoundErr()}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{
{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
{Ocid: "m2", Name: "meta.llama-3.1-70b-instruct", Vendor: "meta"},
{Ocid: "m3", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
},
probeSeq: []probeResult{tt.bad, {200, nil}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
probed, err := gw.ProbeChannel(ctx, ch.ID)
if err != nil || probed.ProbeStatus != "ok" {
t.Fatalf("坏候选后应换候选并判可用: %+v, %v", probed, err)
}
rows, _ := gw.channelModels(ctx, ch.ID)
if len(rows) != 3 {
t.Errorf("模型应全量入库, got %d", len(rows))
}
})
}
}
func TestProbeAuth404StillNoQuota(t *testing.T) {
// 鉴权类 404(NotAuthorizedOrNotFound)仍属租户级,直接定论 no_quota
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
probeCode: 404,
probeErr: stubServiceError{status: 404, msg: "Authorization failed or requested resource not found."},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
probed, _ := gw.ProbeChannel(ctx, ch.ID)
if probed.ProbeStatus != "no_quota" {
t.Errorf("鉴权 404 status = %q, want no_quota", probed.ProbeStatus)
}
}
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
// 微调基座 400 换渠道重试成功,且不计入熔断失败
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
passErrs: []error{finetuneBaseErr()},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
resp, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
if err != nil || resp == nil {
t.Fatalf("RespPassthrough = %v, %v", resp, err)
}
if meta.Retries != 1 || client.passCalls != 2 {
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
}
var chs []model.AiChannel
gw.db.Find(&chs)
for _, ch := range chs {
if ch.FailCount != 0 {
t.Errorf("微调基座 400 不应计入熔断: 渠道 %s failCount=%d", ch.Name, ch.FailCount)
}
}
}
func TestBlacklistLifecycle(t *testing.T) {
// 拉黑:全渠道同名缓存删除、列表与路由立即不可见;重复/空名被拒;
// 移出黑名单后重新同步恢复入池
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{{Ocid: "ocid1..m-eu-frankfurt-1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
ctx := context.Background()
item, err := gw.AddBlacklist(ctx, " meta.llama-3.3-70b-instruct ")
if err != nil || item.Name != "meta.llama-3.3-70b-instruct" {
t.Fatalf("AddBlacklist = %+v, %v", item, err)
}
var left int64
gw.db.Model(&model.AiModelCache{}).Count(&left)
if left != 0 {
t.Errorf("拉黑应删除全部渠道的同名缓存, 剩 %d", left)
}
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "meta.llama-3.3-70b-instruct", ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("拉黑后路由应按未知模型拒绝: %v", err)
}
if _, err := gw.AddBlacklist(ctx, "meta.llama-3.3-70b-instruct"); err == nil {
t.Error("重复拉黑应被拒绝")
}
if _, err := gw.AddBlacklist(ctx, " "); err == nil {
t.Error("空模型名应被拒绝")
}
rows, err := gw.Blacklist(ctx)
if err != nil || len(rows) != 1 {
t.Fatalf("Blacklist = %+v, %v", rows, err)
}
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err != nil {
t.Fatalf("RemoveBlacklist: %v", err)
}
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err == nil {
t.Error("移除不存在的条目应报错")
}
// 移出后重新同步:模型恢复入池
if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
t.Fatalf("SyncModels: %v", err)
}
list, _ := gw.GatewayModels(ctx, "")
if len(list.Data) != 1 {
t.Errorf("移出黑名单并同步后应恢复入池: %+v", list.Data)
}
}
func TestSyncAndProbeFilterBlacklisted(t *testing.T) {
// 同步与探测都过滤黑名单模型:不入库、不进试调候选
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{
{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
},
probeCode: 200,
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
if _, err := gw.AddBlacklist(ctx, "meta.llama-3-70b-instruct"); err != nil {
t.Fatalf("AddBlacklist: %v", err)
}
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
probed, err := gw.ProbeChannel(ctx, ch.ID)
if err != nil || probed.ProbeStatus != "ok" {
t.Fatalf("ProbeChannel = %+v, %v", probed, err)
}
rows, _ := gw.channelModels(ctx, ch.ID)
if len(rows) != 1 || rows[0].Name != "cohere.command-a-03-2025" {
t.Errorf("探测同步应过滤黑名单模型: %+v", rows)
}
if len(client.probedNames) != 1 || client.probedNames[0] != "cohere.command-a-03-2025" {
t.Errorf("试调候选不应包含黑名单模型: %v", client.probedNames)
}
models, err := gw.SyncModels(ctx, ch.ID)
if err != nil || len(models) != 1 || models[0].Name != "cohere.command-a-03-2025" {
t.Errorf("SyncModels 应过滤黑名单模型: %+v, %v", models, err)
}
}
func TestDeprecatingModels(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
@@ -460,7 +676,7 @@ func TestAiEmbeddings(t *testing.T) {
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
}
// embedding 模型名打 chat:同样未知模型
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "cohere.embed-v4.0", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("x")}}}, ""); !errors.Is(err, ErrAiUnknownModel) {
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "cohere.embed-v4.0", ""); !errors.Is(err, ErrAiUnknownModel) {
t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
}
}
@@ -471,7 +687,7 @@ func TestAiContentLogSwitch(t *testing.T) {
t.Fatalf("migrate content log: %v", err)
}
ctx := context.Background()
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "")
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "", nil)
if err != nil {
t.Fatalf("CreateKey: %v", err)
}
@@ -488,6 +704,7 @@ func TestAiContentLogSwitch(t *testing.T) {
t.Error("超过 7 天上限应被拒绝")
}
// 写入与截断(带调用日志关联)
mustCreate(t, gw.db, &model.AiCallLog{ID: 42, KeyID: key.ID, ChannelID: 0})
gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)})
rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20)
if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 {
@@ -506,3 +723,131 @@ func TestAiContentLogSwitch(t *testing.T) {
t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err)
}
}
func TestRespPassthroughSwitchesChannel(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":3}}`),
passErrs: []error{stubServiceError{status: 429}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
payload, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
if err != nil || len(payload) == 0 {
t.Fatalf("RespPassthrough = %v, %v", payload, err)
}
if meta.Retries != 1 || client.passCalls != 2 {
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
}
if len(client.passRegions) == 2 && client.passRegions[0] == client.passRegions[1] {
t.Errorf("重试未换渠道: %v", client.passRegions)
}
}
func TestRespPassthroughNonRetryable(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
passErrs: []error{stubServiceError{status: 400}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
_, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
if err == nil || meta.Retries != 0 || client.passCalls != 1 {
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.passCalls)
}
}
func TestNormalizeKeyModels(t *testing.T) {
tests := []struct {
name string
in []string
want []string
}{
{"nil 输入", nil, nil},
{"全空串", []string{"", " "}, nil},
{"trim 与保序去重", []string{" a ", "b", "a", "", "b "}, []string{"a", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeKeyModels(tt.in)
if len(got) != len(tt.want) {
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
}
for i := range tt.want {
if got[i] != tt.want[i] {
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
}
}
})
}
}
// TestAiKeyModelsPersistence 钉住 serializer:json 字段经 Create 与 Updates(map) 两条路径的往返。
func TestAiKeyModelsPersistence(t *testing.T) {
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
ctx := context.Background()
_, key, err := gw.CreateKey(ctx, "m-key", "model-key-1234", "", []string{" x-model ", "x-model", "y-model", ""})
if err != nil {
t.Fatalf("CreateKey: %v", err)
}
fresh, err := gw.VerifyKey(ctx, "model-key-1234")
if err != nil || len(fresh.Models) != 2 || fresh.Models[0] != "x-model" || fresh.Models[1] != "y-model" {
t.Fatalf("创建后读回 models = %v, %v", fresh.Models, err)
}
set := []string{"z-model"}
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &set); err != nil {
t.Fatalf("UpdateKey 覆盖: %v", err)
}
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
if len(fresh.Models) != 1 || fresh.Models[0] != "z-model" {
t.Fatalf("覆盖后 models = %v", fresh.Models)
}
if err := gw.UpdateKey(ctx, key.ID, "renamed", nil, nil, nil); err != nil {
t.Fatalf("UpdateKey 不传 models: %v", err)
}
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
if len(fresh.Models) != 1 {
t.Fatalf("未传 models 却被改动: %v", fresh.Models)
}
empty := []string{}
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &empty); err != nil {
t.Fatalf("UpdateKey 清空: %v", err)
}
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
if len(fresh.Models) != 0 {
t.Fatalf("清空后 models = %v, want 空", fresh.Models)
}
}
// TestRespPassthroughStreamSwitchesChannel 断言流式直通建立失败按 switchable 换渠道。
func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
passPayload: []byte("data: {\"type\":\"response.completed\"}\n\n"),
passErrs: []error{stubServiceError{status: 503}},
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
stream, meta, err := gw.RespPassthroughStream(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
if err != nil || stream == nil {
t.Fatalf("RespPassthroughStream = %v", err)
}
defer stream.Close()
if meta.Retries != 1 || client.passCalls != 2 {
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
}
payload, _ := io.ReadAll(stream)
if !bytes.Contains(payload, []byte("response.completed")) {
t.Errorf("流内容未透传: %s", payload)
}
}
+61 -206
View File
@@ -8,38 +8,8 @@ import (
"oci-portal/internal/aiwire"
)
// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。
// 有状态特性与内置工具不支持,直接报错(API 层 400)。
func ResponsesToIR(req aiwire.RespRequest) (aiwire.ChatRequest, error) {
if err := respRejectUnsupported(req); err != nil {
return aiwire.ChatRequest{}, err
}
ir := aiwire.ChatRequest{
Model: req.Model,
MaxTokens: req.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
ToolChoice: respToolChoice(req.ToolChoice),
}
if req.Reasoning != nil {
ir.ReasoningEffort = req.Reasoning.Effort
}
if req.Instructions != "" {
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(req.Instructions)})
}
msgs, err := respInputToMessages(req.Input)
if err != nil {
return aiwire.ChatRequest{}, err
}
ir.Messages = append(ir.Messages, msgs...)
ir.Tools = respTools(req.Tools)
ir.ResponseFormat = respFormat(req.Text)
return ir, nil
}
// respRejectUnsupported 拒绝无状态网关无法承接的请求特性。
func respRejectUnsupported(req aiwire.RespRequest) error {
// respRejectStateful 拒绝有状态特性(网关无状态)。
func respRejectStateful(req aiwire.RespRequest) error {
if req.PreviousResponseID != "" {
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
}
@@ -49,195 +19,80 @@ func respRejectUnsupported(req aiwire.RespRequest) error {
if req.Background != nil && *req.Background {
return fmt.Errorf("background 模式不支持")
}
return nil
}
// RespServerTools 报告工具列表是否含 xAI 服务端工具(web_search / x_search)。
func RespServerTools(tools []aiwire.RespTool) bool {
for _, t := range tools {
if t.Type == "web_search" || t.Type == "x_search" {
return true
}
}
return false
}
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
// 只放行 function 与已实测的 web_search / x_search;流式仅在含服务端工具时拒绝
// (工具流式事件形态未实测,不放开)。
func RespPassthroughValidate(req aiwire.RespRequest) error {
if strings.TrimSpace(req.Model) == "" {
return fmt.Errorf("model 不能为空")
}
if req.Stream && RespServerTools(req.Tools) {
return fmt.Errorf("服务端工具暂不支持流式:请去掉 stream 或改用 function 工具")
}
if err := respRejectStateful(req); err != nil {
return err
}
for _, t := range req.Tools {
if t.Type != "function" {
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
switch t.Type {
case "function", "web_search", "x_search":
default:
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search", t.Type)
}
}
return nil
}
// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。
func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) {
if !in.IsArray {
if strings.TrimSpace(in.Text) == "" {
return nil, fmt.Errorf("input 不能为空")
}
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
func RespPassthroughBody(raw []byte) ([]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return nil, fmt.Errorf("解析请求体: %w", err)
}
var msgs []aiwire.ChatMessage
for _, it := range in.Items {
out, err := respItemToMessages(it, msgs)
if err != nil {
return nil, err
}
msgs = out
}
return msgs, nil
body["store"] = false
return json.Marshal(body)
}
// respItemToMessages 追加一个 input item;连续 function_call 合并进同一 assistant 消息
func respItemToMessages(it aiwire.RespItem, msgs []aiwire.ChatMessage) ([]aiwire.ChatMessage, error) {
switch it.Type {
case "", "message":
if it.Content.HasUnsupported() {
return nil, ErrAiUnsupportedBlock
}
role := respRole(it.Role)
return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil
case "function_call":
call := aiwire.ToolCall{ID: it.CallID, Type: "function",
Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}}
if n := len(msgs); n > 0 && msgs[n-1].Role == "assistant" && len(msgs[n-1].ToolCalls) > 0 {
msgs[n-1].ToolCalls = append(msgs[n-1].ToolCalls, call)
return msgs, nil
}
return append(msgs, aiwire.ChatMessage{Role: "assistant", ToolCalls: []aiwire.ToolCall{call}}), nil
case "function_call_output":
return append(msgs, aiwire.ChatMessage{Role: "tool", ToolCallID: it.CallID,
Content: aiwire.NewTextContent(it.OutputText())}), nil
case "reasoning":
return msgs, nil // 推理块不回灌上游
default:
return nil, fmt.Errorf("不支持的 input item 类型 %q", it.Type)
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)
func RespPassthroughUsage(payload []byte) *aiwire.Usage {
var root struct {
Usage *aiwire.RespUsage `json:"usage"`
}
}
// respRole 归一 item 角色;developer 视为 system,缺省 user。
func respRole(role string) string {
switch role {
case "system", "developer":
return "system"
case "assistant":
return "assistant"
default:
return "user"
}
}
// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。
func respContentToIR(c aiwire.RespContent) aiwire.Content {
var parts []aiwire.ContentPart
hasImage := false
for _, p := range c.Parts {
switch p.Type {
case "input_image":
parts = append(parts, aiwire.ContentPart{Type: "image_url",
ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}})
hasImage = true
default:
if p.Text != "" {
parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text})
}
}
}
if !c.IsArray || !hasImage {
return aiwire.NewTextContent(c.JoinText())
}
return aiwire.NewPartsContent(parts)
}
// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。
func respTools(tools []aiwire.RespTool) []aiwire.Tool {
if len(tools) == 0 {
if json.Unmarshal(payload, &root) != nil || root.Usage == nil {
return nil
}
out := make([]aiwire.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, aiwire.Tool{Type: "function",
Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}})
usage := &aiwire.Usage{PromptTokens: root.Usage.InputTokens,
CompletionTokens: root.Usage.OutputTokens, TotalTokens: root.Usage.TotalTokens}
if cached := root.Usage.InputTokensDetails.CachedTokens; cached > 0 {
usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached}
}
return out
return usage
}
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
// allowed_tools 等对象形态降级 auto
func respToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
// RespStreamCompletedUsage 从一行 SSE data JSON 中提取 response.completed 事件的
// usage;非 completed 事件或解析失败返回 nil。流式直通逐行喂入,最后一次非 nil 生效
func RespStreamCompletedUsage(data []byte) *aiwire.Usage {
var ev struct {
Type string `json:"type"`
Response json.RawMessage `json:"response"`
}
if json.Unmarshal(data, &ev) != nil || ev.Type != "response.completed" || len(ev.Response) == 0 {
return nil
}
t := strings.TrimSpace(string(raw))
if strings.HasPrefix(t, "\"") {
return raw
}
var obj struct {
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" {
return json.RawMessage(`"auto"`)
}
out, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": obj.Name}})
return out
}
// respFormat 把 text.format 转成 response_format;text 形态无需显式指定。
func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat {
if text == nil || text.Format == nil {
return nil
}
switch text.Format.Type {
case "json_object":
return &aiwire.ResponseFormat{Type: "json_object"}
case "json_schema":
spec, _ := json.Marshal(map[string]any{
"name": text.Format.Name, "schema": json.RawMessage(text.Format.Schema), "strict": text.Format.Strict,
})
return &aiwire.ResponseFormat{Type: "json_schema", JSONSchema: spec}
default:
return nil
}
}
// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。
func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response {
out := aiwire.Response{ID: id, Object: "response", CreatedAt: created,
Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false}
if len(resp.Choices) == 0 {
return out
}
choice := resp.Choices[0]
if text := choice.Message.Content.JoinText(); text != "" {
out.Output = append(out.Output, respMessageItem(id, 0, text))
}
for i, tc := range choice.Message.ToolCalls {
out.Output = append(out.Output, aiwire.RespOutItem{Type: "function_call",
ID: respItemID(id, "fc", len(out.Output)+i), Status: "completed",
CallID: tc.ID, Name: tc.Function.Name, Arguments: respArgs(tc.Function.Arguments)})
}
if choice.FinishReason == "length" {
out.Status = "incomplete"
out.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
}
out.Usage = respUsage(resp.Usage)
return out
}
func respMessageItem(respID string, idx int, text string) aiwire.RespOutItem {
return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx),
Status: "completed", Role: "assistant",
Content: []aiwire.RespOutPart{{Type: "output_text", Text: text, Annotations: []any{}}}}
}
// respItemID 从响应 ID 派生确定性 item ID。
func respItemID(respID, kind string, idx int) string {
return fmt.Sprintf("%s_%s_%d", kind, strings.TrimPrefix(respID, "resp_"), idx)
}
// respArgs 保证 arguments 是合法 JSON 字符串(空实参回退 {})。
func respArgs(args string) string {
if strings.TrimSpace(args) == "" {
return "{}"
}
return args
}
// respUsage 把 IR 用量改写为 Responses 命名口径。
func respUsage(u *aiwire.Usage) *aiwire.RespUsage {
if u == nil {
return nil
}
return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}}
return RespPassthroughUsage(ev.Response)
}
-199
View File
@@ -1,199 +0,0 @@
package service
import (
"strings"
"oci-portal/internal/aiwire"
)
// RespEvent 是一条 Responses SSE 语义事件(event 名 + data 载荷)。
type RespEvent struct {
Event string
Data map[string]any
}
// RespStream 把 IR chunk 流聚合为 Responses 语义事件序列。
// 与 AnthStream 同构,但终态事件须携带完整 Response 快照,故全程缓冲文本与实参。
type RespStream struct {
id string
model string
created int64
seq int
started bool
items []aiwire.RespOutItem
msgOpen bool
text strings.Builder
tool aiwire.RespOutItem
toolIdx int
tOpen bool
args strings.Builder
finish string
usage *aiwire.Usage
}
// NewRespStream 构造状态机;id 形如 resp_*,created 为响应时间戳。
func NewRespStream(id, model string, created int64) *RespStream {
return &RespStream{id: id, model: model, created: created}
}
// Feed 消化一个上游 chunk,返回应立即下发的事件。
func (s *RespStream) Feed(chunk aiwire.ChatChunk) []RespEvent {
var evs []RespEvent
if !s.started {
s.started = true
evs = append(evs, s.respEvent("response.created", "in_progress"),
s.respEvent("response.in_progress", "in_progress"))
}
if chunk.Usage != nil {
s.usage = chunk.Usage
}
if len(chunk.Choices) == 0 {
return evs
}
choice := chunk.Choices[0]
if choice.Delta.Content != "" {
evs = append(evs, s.feedText(choice.Delta.Content)...)
}
for _, tc := range choice.Delta.ToolCalls {
evs = append(evs, s.feedTool(tc)...)
}
if choice.FinishReason != nil {
s.finish = *choice.FinishReason
}
return evs
}
// Finish 关闭未闭合的块并产出终态事件(带完整 Response 快照)。
func (s *RespStream) Finish() []RespEvent {
var evs []RespEvent
if !s.started {
s.started = true
evs = append(evs, s.respEvent("response.created", "in_progress"))
}
evs = append(evs, s.closeMsg()...)
evs = append(evs, s.closeTool()...)
status := "completed"
if s.finish == "length" {
status = "incomplete"
}
return append(evs, s.respEvent("response."+status, status))
}
// Usage 返回聚合到的用量(可能为 nil),供调用日志。
func (s *RespStream) Usage() *aiwire.Usage { return s.usage }
// ev 构造带自增 sequence_number 的事件。
func (s *RespStream) ev(typ string, kv map[string]any) RespEvent {
s.seq++
data := map[string]any{"type": typ, "sequence_number": s.seq}
for k, v := range kv {
data[k] = v
}
return RespEvent{Event: typ, Data: data}
}
// respEvent 构造携带 Response 快照的生命周期事件。
func (s *RespStream) respEvent(typ, status string) RespEvent {
return s.ev(typ, map[string]any{"response": s.snapshot(status)})
}
// snapshot 组装当前累计状态的 Response 对象。
func (s *RespStream) snapshot(status string) aiwire.Response {
resp := aiwire.Response{ID: s.id, Object: "response", CreatedAt: s.created,
Status: status, Model: s.model, Store: false,
Output: append([]aiwire.RespOutItem{}, s.items...)}
resp.Usage = respUsage(s.usage)
if status == "incomplete" {
resp.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
}
return resp
}
// feedText 处理文本增量:必要时开 message item 与 content part。
func (s *RespStream) feedText(delta string) []RespEvent {
evs := s.closeTool()
if !s.msgOpen {
s.msgOpen = true
s.text.Reset()
item := aiwire.RespOutItem{Type: "message", ID: s.itemID("msg"), Status: "in_progress",
Role: "assistant", Content: []aiwire.RespOutPart{}}
evs = append(evs, s.ev("response.output_item.added", map[string]any{
"output_index": len(s.items), "item": item}))
evs = append(evs, s.ev("response.content_part.added", map[string]any{
"item_id": item.ID, "output_index": len(s.items), "content_index": 0,
"part": aiwire.RespOutPart{Type: "output_text", Text: "", Annotations: []any{}}}))
}
s.text.WriteString(delta)
return append(evs, s.ev("response.output_text.delta", map[string]any{
"item_id": s.itemID("msg"), "output_index": len(s.items), "content_index": 0, "delta": delta}))
}
// closeMsg 闭合当前 message item(text done → part done → item done)。
func (s *RespStream) closeMsg() []RespEvent {
if !s.msgOpen {
return nil
}
s.msgOpen = false
id, idx, full := s.itemID("msg"), len(s.items), s.text.String()
part := aiwire.RespOutPart{Type: "output_text", Text: full, Annotations: []any{}}
item := aiwire.RespOutItem{Type: "message", ID: id, Status: "completed",
Role: "assistant", Content: []aiwire.RespOutPart{part}}
evs := []RespEvent{
s.ev("response.output_text.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "text": full}),
s.ev("response.content_part.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "part": part}),
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": item}),
}
s.items = append(s.items, item)
return evs
}
// feedTool 处理工具调用增量:新 Index 先闭合旧调用再开新 item。
func (s *RespStream) feedTool(tc aiwire.ToolCallDelta) []RespEvent {
evs := s.closeMsg()
if s.tOpen && tc.Index != s.toolIdx {
evs = append(evs, s.closeTool()...)
}
if !s.tOpen {
s.tOpen, s.toolIdx = true, tc.Index
s.args.Reset()
s.tool = aiwire.RespOutItem{Type: "function_call", ID: s.itemID("fc"), Status: "in_progress",
CallID: tc.ID, Name: tc.Function.Name}
evs = append(evs, s.ev("response.output_item.added", map[string]any{
"output_index": len(s.items), "item": s.tool}))
}
if tc.ID != "" && s.tool.CallID == "" {
s.tool.CallID = tc.ID
}
if tc.Function.Name != "" && s.tool.Name == "" {
s.tool.Name = tc.Function.Name
}
if tc.Function.Arguments == "" {
return evs
}
s.args.WriteString(tc.Function.Arguments)
return append(evs, s.ev("response.function_call_arguments.delta", map[string]any{
"item_id": s.tool.ID, "output_index": len(s.items), "delta": tc.Function.Arguments}))
}
// closeTool 闭合当前 function_call item(arguments done → item done)。
func (s *RespStream) closeTool() []RespEvent {
if !s.tOpen {
return nil
}
s.tOpen = false
s.tool.Arguments = respArgs(s.args.String())
s.tool.Status = "completed"
idx := len(s.items)
evs := []RespEvent{
s.ev("response.function_call_arguments.done", map[string]any{
"item_id": s.tool.ID, "output_index": idx, "arguments": s.tool.Arguments}),
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": s.tool}),
}
s.items = append(s.items, s.tool)
return evs
}
// itemID 按当前 output 序号派生确定性 item ID。
func (s *RespStream) itemID(kind string) string {
return respItemID(s.id, kind, len(s.items))
}
+106 -135
View File
@@ -17,47 +17,6 @@ func respReq(t *testing.T, raw string) aiwire.RespRequest {
return req
}
func TestResponsesToIR(t *testing.T) {
req := respReq(t, `{
"model": "meta.llama-3.3-70b-instruct",
"instructions": "你是助手",
"max_output_tokens": 128,
"input": [
{"role": "user", "content": "查天气"},
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"},
{"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]}
],
"tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}],
"tool_choice": {"type": "function", "name": "get_weather"},
"text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}}
}`)
ir, err := ResponsesToIR(req)
if err != nil {
t.Fatalf("ResponsesToIR: %v", err)
}
roles := make([]string, 0, len(ir.Messages))
for _, m := range ir.Messages {
roles = append(roles, m.Role)
}
want := []string{"system", "user", "assistant", "tool", "assistant"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Errorf("roles = %v, want %v", roles, want)
}
if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" {
t.Errorf("tool call 链接错误: %+v", ir.Messages)
}
if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" {
t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools)
}
if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") {
t.Errorf("tool_choice = %s", ir.ToolChoice)
}
if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" {
t.Errorf("response_format = %+v", ir.ResponseFormat)
}
}
func deref(p *int) int {
if p == nil {
return 0
@@ -65,112 +24,124 @@ func deref(p *int) int {
return *p
}
func TestResponsesToIRRejects(t *testing.T) {
cases := []struct{ name, raw string }{
{"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`},
{"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`},
{"background", `{"model":"m","input":"hi","background":true}`},
{"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`},
{"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`},
{"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`},
func TestRespServerTools(t *testing.T) {
tests := []struct {
name string
tools []aiwire.RespTool
want bool
}{
{"web_search", []aiwire.RespTool{{Type: "web_search"}}, true},
{"x_search混用", []aiwire.RespTool{{Type: "function", Name: "f"}, {Type: "x_search"}}, true},
{"仅function", []aiwire.RespTool{{Type: "function", Name: "f"}}, false},
{"空", nil, false},
{"其他类型", []aiwire.RespTool{{Type: "code_interpreter"}}, false},
}
for _, c := range cases {
if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil {
t.Errorf("%s 应被拒绝", c.name)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := RespServerTools(test.tools); got != test.want {
t.Fatalf("got %v, want %v", got, test.want)
}
})
}
// input 字符串形态 + store/reasoning 忽略项不报错
req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`)
ir, err := ResponsesToIR(req)
if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" {
t.Errorf("字符串 input = %+v, %v", ir.Messages, err)
}
func TestRespPassthroughValidate(t *testing.T) {
prev := "resp_1"
bg := true
tests := []struct {
name string
req aiwire.RespRequest
wantErr bool
}{
{"合法", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
{"混用function", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "function", Name: "f"}}}, false},
{"缺model", aiwire.RespRequest{Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
{"工具加流式拒绝", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
{"无工具流式放行", aiwire.RespRequest{Model: "m", Stream: true}, false},
{"function工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "function", Name: "f"}}}, false},
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "mcp"}}}, true},
}
// input_image(url 形态)放行并保留图文顺序
req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`)
ir2, err := ResponsesToIR(req2)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := RespPassthroughValidate(test.req)
if (err != nil) != test.wantErr {
t.Fatalf("err = %v, wantErr %v", err, test.wantErr)
}
})
}
}
func TestRespPassthroughBody(t *testing.T) {
raw := []byte(`{"model":"m","input":"hi","stream":true,"store":true,"max_output_tokens":128,"custom_field":{"a":1.5}}`)
out, err := RespPassthroughBody(raw)
if err != nil {
t.Fatalf("input_image 应放行: %v", err)
t.Fatalf("RespPassthroughBody: %v", err)
}
parts := ir2.Messages[0].Content.Parts
if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" {
t.Errorf("parts = %+v", parts)
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
t.Fatalf("unmarshal out: %v", err)
}
if body["store"] != false {
t.Errorf("store 应强制 false, got %v", body["store"])
}
if body["stream"] != true {
t.Errorf("stream 应原样保留, got %v", body["stream"])
}
if string(out) == "" || !strings.Contains(string(out), `"max_output_tokens":128`) {
t.Errorf("数值字段应保真: %s", out)
}
if !strings.Contains(string(out), `"custom_field"`) {
t.Errorf("未知字段应保留: %s", out)
}
if _, err := RespPassthroughBody([]byte("not-json")); err == nil {
t.Error("非法 JSON 应报错")
}
}
func TestIRRespToResponses(t *testing.T) {
resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{
Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"),
ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function",
Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}},
FinishReason: "length",
}}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}
out := IRRespToResponses(resp, "resp_abc", 1751966400)
if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
t.Errorf("length 应映射 incomplete: %+v", out)
func TestRespPassthroughUsage(t *testing.T) {
tests := []struct {
name string
payload string
wantNil bool
prompt, cached int
}{
{"完整", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12,"input_tokens_details":{"cached_tokens":5}}}`, false, 9, 5},
{"无细分", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}`, false, 9, 0},
{"无usage", `{"id":"resp_1"}`, true, 0, 0},
{"非法JSON", `xx`, true, 0, 0},
}
if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" {
t.Fatalf("output = %+v", out.Output)
}
if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" {
t.Errorf("item 装配错误: %+v", out.Output)
}
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store {
t.Errorf("usage/store 错误: %+v", out)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
usage := RespPassthroughUsage([]byte(test.payload))
if (usage == nil) != test.wantNil {
t.Fatalf("usage = %v, wantNil %v", usage, test.wantNil)
}
if usage == nil {
return
}
if usage.PromptTokens != test.prompt || usage.CachedTokens() != test.cached {
t.Fatalf("prompt=%d cached=%d, want %d/%d", usage.PromptTokens, usage.CachedTokens(), test.prompt, test.cached)
}
})
}
}
func chunkText(text string) aiwire.ChatChunk {
return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}}
}
func TestRespStreamTextAndTool(t *testing.T) {
st := NewRespStream("resp_x", "m", 1751966400)
var types []string
collect := func(evs []RespEvent) {
for _, ev := range evs {
types = append(types, ev.Event)
// TestRespStreamCompletedUsage 断言流式 usage 只从 completed 事件提取。
func TestRespStreamCompletedUsage(t *testing.T) {
completed := []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}}`)
if u := RespStreamCompletedUsage(completed); u == nil || u.PromptTokens != 10 || u.CompletionTokens != 5 ||
u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 4 {
t.Fatalf("completed 事件 usage 解析失败: %+v", u)
}
for _, data := range []string{
`{"type":"response.output_text.delta","delta":"hi"}`,
`{"type":"response.completed"}`,
`not-json`,
} {
if u := RespStreamCompletedUsage([]byte(data)); u != nil {
t.Fatalf("非 completed 或缺 response 应返回 nil: %s", data)
}
}
collect(st.Feed(chunkText("你")))
collect(st.Feed(chunkText("好")))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}}))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}},
Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}}))
finish := st.Finish()
collect(finish)
want := []string{
"response.created", "response.in_progress",
"response.output_item.added", "response.content_part.added", "response.output_text.delta",
"response.output_text.delta",
"response.output_text.done", "response.content_part.done", "response.output_item.done",
"response.output_item.added", "response.function_call_arguments.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done", "response.output_item.done",
"response.completed",
}
if strings.Join(types, "\n") != strings.Join(want, "\n") {
t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n"))
}
last := finish[len(finish)-1].Data
resp := last["response"].(aiwire.Response)
if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" {
t.Errorf("终态快照 = %+v", resp.Output)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 10 {
t.Errorf("终态 usage = %+v", resp.Usage)
}
// sequence_number 严格递增
if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) {
t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want))
}
}
func TestRespStreamEmpty(t *testing.T) {
st := NewRespStream("resp_e", "m", 1)
evs := st.Finish()
if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" {
t.Errorf("空流事件 = %+v", evs)
}
}
+305
View File
@@ -0,0 +1,305 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"slices"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
)
// 告警规则约束与命中记录保留期(窗口计数之外多留几天便于排查)。
const (
alertMaxThreshold = 100
alertMaxWindowMin = 1440
alertHitRetention = 7 * 24 * time.Hour
alertSourceIPIn = "in"
alertSourceIPNotIn = "notin"
)
// ErrInvalidAlertRule 标记规则字段非法,api 层映射 400。
var ErrInvalidAlertRule = fmt.Errorf("告警规则字段非法")
// ListAlertRules 返回全部告警规则(创建顺序)。
func (s *LogEventService) ListAlertRules(ctx context.Context) ([]model.AlertRule, error) {
var rules []model.AlertRule
if err := s.db.WithContext(ctx).Order("id").Find(&rules).Error; err != nil {
return nil, fmt.Errorf("list alert rules: %w", err)
}
return rules, nil
}
// CreateAlertRule 校验并创建规则。
func (s *LogEventService) CreateAlertRule(ctx context.Context, rule model.AlertRule) (model.AlertRule, error) {
if err := validateAlertRule(&rule); err != nil {
return model.AlertRule{}, err
}
rule.ID = 0
if err := s.db.WithContext(ctx).Create(&rule).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("create alert rule: %w", err)
}
return rule, nil
}
// UpdateAlertRule 校验并整体覆盖规则(含启停)。
func (s *LogEventService) UpdateAlertRule(ctx context.Context, id uint, rule model.AlertRule) (model.AlertRule, error) {
if err := validateAlertRule(&rule); err != nil {
return model.AlertRule{}, err
}
var cur model.AlertRule
if err := s.db.WithContext(ctx).First(&cur, id).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("find alert rule %d: %w", id, err)
}
rule.ID, rule.CreatedAt = cur.ID, cur.CreatedAt
if err := s.db.WithContext(ctx).Save(&rule).Error; err != nil {
return model.AlertRule{}, fmt.Errorf("update alert rule: %w", err)
}
return rule, nil
}
// DeleteAlertRule 删除规则及其命中记录。
func (s *LogEventService) DeleteAlertRule(ctx context.Context, id uint) error {
if err := s.db.WithContext(ctx).Delete(&model.AlertRule{}, id).Error; err != nil {
return fmt.Errorf("delete alert rule: %w", err)
}
if err := s.db.WithContext(ctx).Where("rule_id = ?", id).Delete(&model.AlertRuleHit{}).Error; err != nil {
return fmt.Errorf("delete alert rule hits: %w", err)
}
return nil
}
// validateAlertRule 校验字段并归一化;非法时返回含具体原因的 ErrInvalidAlertRule 包装。
func validateAlertRule(rule *model.AlertRule) error {
rule.Name = strings.TrimSpace(rule.Name)
if rule.Name == "" {
return fmt.Errorf("%w: 名称必填", ErrInvalidAlertRule)
}
if rule.SourceIPMode == "" {
rule.SourceIPMode = alertSourceIPIn
}
if rule.SourceIPMode != alertSourceIPIn && rule.SourceIPMode != alertSourceIPNotIn {
return fmt.Errorf("%w: 来源 IP 模式须为 in/notin", ErrInvalidAlertRule)
}
if rule.Threshold < 1 || rule.Threshold > alertMaxThreshold {
return fmt.Errorf("%w: 阈值须在 1-%d 之间", ErrInvalidAlertRule, alertMaxThreshold)
}
if rule.Threshold > 1 && (rule.WindowMinutes < 1 || rule.WindowMinutes > alertMaxWindowMin) {
return fmt.Errorf("%w: 阈值>1 时窗口须在 1-%d 分钟之间", ErrInvalidAlertRule, alertMaxWindowMin)
}
if rule.EventTypes != "" {
rule.EventTypes = normalizeCSV(rule.EventTypes)
}
return validateAlertRuleIPs(rule)
}
// validateAlertRuleIPs 归一化并校验来源 IP 列表(裸 IP 或 CIDR)。
func validateAlertRuleIPs(rule *model.AlertRule) error {
if rule.SourceIPs == "" {
return nil
}
rule.SourceIPs = normalizeCSV(rule.SourceIPs)
for _, item := range strings.Split(rule.SourceIPs, ",") {
if _, err := parseIPMatcher(item); err != nil {
return fmt.Errorf("%w: 来源 IP %q 不是合法的 IP 或 CIDR", ErrInvalidAlertRule, item)
}
}
return nil
}
// normalizeCSV 去除各项空白与空项后重组逗号分隔串。
func normalizeCSV(s string) string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return strings.Join(out, ",")
}
// parseIPMatcher 把裸 IP 或 CIDR 解析为前缀(裸 IP 视为单地址前缀)。
func parseIPMatcher(item string) (netip.Prefix, error) {
if strings.Contains(item, "/") {
return netip.ParsePrefix(item)
}
addr, err := netip.ParseAddr(item)
if err != nil {
return netip.Prefix{}, err
}
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
// ipListMatch 报告 ip 是否命中列表中的任一前缀;ip 解析失败视为未命中。
func ipListMatch(list, ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
for _, item := range strings.Split(list, ",") {
if p, err := parseIPMatcher(item); err == nil && p.Contains(addr) {
return true
}
}
return false
}
// ruleHits 报告事件是否命中规则的全部条件(AND 语义,空条件视为任意)。
func ruleHits(rule model.AlertRule, e *model.LogEvent, p parsedEvent) bool {
if rule.OciConfigID != 0 && rule.OciConfigID != e.OciConfigID {
return false
}
name := relayEventShortName(p.EventType)
if rule.EventTypes != "" && !slices.Contains(strings.Split(rule.EventTypes, ","), name) {
return false
}
if rule.ResourceMatch != "" && !strings.Contains(p.ResourceName, rule.ResourceMatch) {
return false
}
return ruleIPHits(rule, p.SourceIP)
}
// ruleIPHits 按模式判定来源 IP 条件:in 命中列表告警;notin 不在列表才告警,
// 事件缺 IP 字段时 notin 不告警(避免解析缺字段导致白名单误报)。
func ruleIPHits(rule model.AlertRule, ip string) bool {
if rule.SourceIPs == "" {
return true
}
if rule.SourceIPMode == alertSourceIPNotIn {
return ip != "" && !ipListMatch(rule.SourceIPs, ip)
}
return ipListMatch(rule.SourceIPs, ip)
}
// matchAlertRules 对一条已解析事件执行全部启用规则;任何内部错误只记日志,不影响解析主流程。
func (s *LogEventService) matchAlertRules(ctx context.Context, rules []model.AlertRule, e *model.LogEvent, p parsedEvent) {
if s.notifier == nil {
return
}
for _, rule := range rules {
if !rule.Enabled || !ruleHits(rule, e, p) {
continue
}
count, ok := s.recordAlertHit(ctx, rule, e)
if !ok || count < rule.Threshold || !s.alertCooldownPass(rule) {
continue
}
s.notifier.SendTemplateAsync("audit_alert", map[string]string{
"rule": rule.Name, "tenant": s.configAlias(ctx, e.OciConfigID),
"event": relayEventShortName(p.EventType), "resource": p.ResourceName,
"ip": p.SourceIP, "count": fmt.Sprint(count),
})
}
}
// recordAlertHit 落一条命中并返回窗口内累计次数;阈值 1 的规则免计数直接触发。
func (s *LogEventService) recordAlertHit(ctx context.Context, rule model.AlertRule, e *model.LogEvent) (int, bool) {
count, err := s.recordAlertHitTx(ctx, rule, e)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("alert rule hit record: %v", err)
}
return 0, false
}
return count, true
}
func (s *LogEventService) recordAlertHitTx(ctx context.Context, rule model.AlertRule, event *model.LogEvent) (int, error) {
count := 0
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
count, err = insertAlertHit(tx, rule, event)
return err
})
return count, err
}
// insertAlertHit 按 rule→event 锁顺序确认引用存在后插入并统计窗口命中。
func insertAlertHit(tx *gorm.DB, rule model.AlertRule, event *model.LogEvent) (int, error) {
if err := lockAlertRefs(tx, rule.ID, event.ID); err != nil {
return 0, err
}
if rule.Threshold <= 1 {
return 1, nil
}
now := time.Now()
hit := model.AlertRuleHit{RuleID: rule.ID, LogEventID: event.ID, HitAt: now}
if err := tx.Create(&hit).Error; err != nil {
return 0, fmt.Errorf("create alert rule hit: %w", err)
}
var count int64
cutoff := now.Add(-time.Duration(rule.WindowMinutes) * time.Minute)
err := tx.Model(&model.AlertRuleHit{}).
Where("rule_id = ? AND hit_at >= ?", rule.ID, cutoff).Count(&count).Error
if err != nil {
return 0, fmt.Errorf("count alert rule hits: %w", err)
}
return int(count), nil
}
func lockAlertRefs(tx *gorm.DB, ruleID, eventID uint) error {
var rule model.AlertRule
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&rule, ruleID).Error; err != nil {
return fmt.Errorf("lock alert rule %d: %w", ruleID, err)
}
var event model.LogEvent
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&event, eventID).Error; err != nil {
return fmt.Errorf("lock log event %d: %w", eventID, err)
}
return nil
}
// alertCooldownPass 报告规则是否已过冷却窗口;通过即记录本次发送时刻。
// 阈值 1 的规则无冷却(每次命中即时告警,与既有云端事件通知一致)。
func (s *LogEventService) alertCooldownPass(rule model.AlertRule) bool {
if rule.Threshold <= 1 {
return true
}
s.alertMu.Lock()
defer s.alertMu.Unlock()
window := time.Duration(rule.WindowMinutes) * time.Minute
if last, ok := s.alertSentAt[rule.ID]; ok && time.Since(last) < window {
return false
}
if s.alertSentAt == nil {
s.alertSentAt = map[uint]time.Time{}
}
s.alertSentAt[rule.ID] = time.Now()
return true
}
// ClearAlertCooldown 清除已删除租户规则的进程内冷却状态。
func (s *LogEventService) ClearAlertCooldown(ruleIDs []uint) {
s.alertMu.Lock()
defer s.alertMu.Unlock()
for _, id := range ruleIDs {
delete(s.alertSentAt, id)
}
}
// loadEnabledAlertRules 载入启用中的规则;失败时返回空集并记日志(解析主流程照常)。
func (s *LogEventService) loadEnabledAlertRules(ctx context.Context) []model.AlertRule {
var rules []model.AlertRule
err := s.db.WithContext(ctx).Where("enabled = ?", true).Order("id").Find(&rules).Error
if err != nil {
log.Printf("load alert rules: %v", err)
return nil
}
return rules
}
// cleanupAlertHits 删除保留期外的命中记录(随 cleanupOnce 周期执行)。
func (s *LogEventService) cleanupAlertHits(ctx context.Context) {
cutoff := time.Now().Add(-alertHitRetention)
if err := s.db.WithContext(ctx).Where("hit_at < ?", cutoff).Delete(&model.AlertRuleHit{}).Error; err != nil {
log.Printf("cleanup alert hits: %v", err)
}
}
+345
View File
@@ -0,0 +1,345 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
func TestValidateAlertRule(t *testing.T) {
tests := []struct {
name string
rule model.AlertRule
wantErr string
check func(t *testing.T, r model.AlertRule)
}{
{name: "名称必填", rule: model.AlertRule{Threshold: 1}, wantErr: "名称"},
{name: "模式非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: "any"}, wantErr: "in/notin"},
{name: "阈值越界", rule: model.AlertRule{Name: "r", Threshold: 101}, wantErr: "阈值"},
{name: "阈值>1须带窗口", rule: model.AlertRule{Name: "r", Threshold: 3}, wantErr: "窗口"},
{name: "IP 非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "300.1.1.1"}, wantErr: "IP"},
{name: "CIDR 合法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "10.0.0.0/8, 1.2.3.4"},
check: func(t *testing.T, r model.AlertRule) {
if r.SourceIPs != "10.0.0.0/8,1.2.3.4" {
t.Errorf("SourceIPs = %q, 应去空白归一化", r.SourceIPs)
}
if r.SourceIPMode != alertSourceIPIn {
t.Errorf("SourceIPMode = %q, 应默认 in", r.SourceIPMode)
}
}},
{name: "事件清单归一化", rule: model.AlertRule{Name: "r", Threshold: 1, EventTypes: " TerminateInstance , CreateApiKey ,"},
check: func(t *testing.T, r model.AlertRule) {
if r.EventTypes != "TerminateInstance,CreateApiKey" {
t.Errorf("EventTypes = %q", r.EventTypes)
}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := tt.rule
err := validateAlertRule(&rule)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validateAlertRule: %v", err)
}
if tt.check != nil {
tt.check(t, rule)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestRuleHits(t *testing.T) {
base := model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: alertSourceIPIn}
ev := &model.LogEvent{OciConfigID: 7}
parsed := parsedEvent{
EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
SourceIP: "203.0.113.8",
ResourceName: "web-server-1",
}
tests := []struct {
name string
mod func(r *model.AlertRule)
p *parsedEvent
want bool
}{
{name: "空条件全命中", mod: func(r *model.AlertRule) {}, want: true},
{name: "租户匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 7 }, want: true},
{name: "租户不匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 8 }, want: false},
{name: "事件短名命中", mod: func(r *model.AlertRule) { r.EventTypes = "LaunchInstance,TerminateInstance" }, want: true},
{name: "事件不在清单", mod: func(r *model.AlertRule) { r.EventTypes = "CreateUser" }, want: false},
{name: "资源子串命中", mod: func(r *model.AlertRule) { r.ResourceMatch = "web-" }, want: true},
{name: "资源不含", mod: func(r *model.AlertRule) { r.ResourceMatch = "db-" }, want: false},
{name: "IP in 命中 CIDR", mod: func(r *model.AlertRule) { r.SourceIPs = "203.0.113.0/24" }, want: true},
{name: "IP in 未命中", mod: func(r *model.AlertRule) { r.SourceIPs = "10.0.0.0/8" }, want: false},
{name: "IP notin 白名单外告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
}, want: true},
{name: "IP notin 白名单内不告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "203.0.113.8", alertSourceIPNotIn
}, want: false},
{name: "notin 事件缺 IP 不告警", mod: func(r *model.AlertRule) {
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
}, p: &parsedEvent{EventType: parsed.EventType}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := base
tt.mod(&rule)
p := parsed
if tt.p != nil {
p = *tt.p
}
if got := ruleHits(rule, ev, p); got != tt.want {
t.Errorf("ruleHits = %v, want %v", got, tt.want)
}
})
}
}
func TestAlertRuleCRUD(t *testing.T) {
svc, _, _ := newLogEventEnv(t)
ctx := context.Background()
created, err := svc.CreateAlertRule(ctx, model.AlertRule{Name: "非白名单终止", Enabled: true, Threshold: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 {
t.Fatal("create 未回填 ID")
}
if _, err := svc.CreateAlertRule(ctx, model.AlertRule{Threshold: 1}); err == nil {
t.Fatal("空名称应校验失败")
}
created.Enabled = false
created.EventTypes = "TerminateInstance"
updated, err := svc.UpdateAlertRule(ctx, created.ID, created)
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Enabled || updated.EventTypes != "TerminateInstance" {
t.Fatalf("update 未生效: %+v", updated)
}
rules, err := svc.ListAlertRules(ctx)
if err != nil || len(rules) != 1 {
t.Fatalf("list = %v, %v", rules, err)
}
if err := svc.DeleteAlertRule(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if rules, _ := svc.ListAlertRules(ctx); len(rules) != 0 {
t.Fatalf("delete 后仍有 %d 条", len(rules))
}
}
// auditEventPayload 构造一条含资源与来源 IP 的 CloudEvents 审计消息。
func auditEventPayload(event, resource, ip string) string {
return fmt.Sprintf(`{"eventType":"com.oraclecloud.ComputeApi.%s","source":"ComputeApi",`+
`"eventTime":"2026-07-10T08:00:00Z","data":{"resourceName":%q,"identity":{"ipAddress":%q}}}`,
event, resource, ip)
}
// newAlertNotifyEnv 组装带假 Telegram 通道的告警测试环境。
func newAlertNotifyEnv(t *testing.T) (*LogEventService, *telegramCapture, func()) {
t.Helper()
svc, db, _ := newLogEventEnv(t)
srv, rec := newFakeTelegram(t, `{"ok":true}`)
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
settings := NewSettingService(db, cipher)
token := "123456:AAfake"
if err := settings.UpdateTelegram(context.Background(),
UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}); err != nil {
t.Fatalf("update telegram: %v", err)
}
n := NewNotifier(settings)
n.base = srv.URL
svc.SetNotifier(n, settings)
return svc, rec, n.Wait
}
func TestMatchAlertRulesNotify(t *testing.T) {
svc, rec, wait := newAlertNotifyEnv(t)
ctx := context.Background()
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
Name: "白名单外终止", Enabled: true, Threshold: 1,
EventTypes: "TerminateInstance", SourceIPs: "10.0.0.0/8", SourceIPMode: alertSourceIPNotIn,
})
if err != nil {
t.Fatalf("create rule: %v", err)
}
// 命中:白名单外 IP;不命中:白名单内 IP
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "203.0.113.8"))
mustIngest(t, svc, "m2", auditEventPayload("TerminateInstance", "web-2", "10.1.2.3"))
svc.parseOnce(ctx)
wait()
alerts := auditAlerts(rec.snapshot())
joined := strings.Join(alerts, "\n---\n")
if !strings.Contains(joined, "白名单外终止") || !strings.Contains(joined, "web-1") {
t.Fatalf("应收到含规则名与资源的告警,got %q", joined)
}
if strings.Contains(joined, "web-2") {
t.Fatalf("白名单内事件不应告警,got %q", joined)
}
}
// auditAlerts 过滤出审计告警推送(排除既有 notifyCritical 的云端事件通知)。
func auditAlerts(texts []string) []string {
var out []string
for _, s := range texts {
if strings.Contains(s, "审计告警") {
out = append(out, s)
}
}
return out
}
func TestAlertThresholdWindow(t *testing.T) {
svc, rec, wait := newAlertNotifyEnv(t)
ctx := context.Background()
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
Name: "登录风暴", Enabled: true, Threshold: 3, WindowMinutes: 5, EventTypes: "InteractiveLogin",
})
if err != nil {
t.Fatalf("create rule: %v", err)
}
for i := 1; i <= 4; i++ {
mustIngest(t, svc, fmt.Sprint("login-", i),
auditEventPayload("InteractiveLogin", "user@x.com", "203.0.113.8"))
}
svc.parseOnce(ctx)
wait()
alerts := auditAlerts(rec.snapshot())
if len(alerts) != 1 {
t.Fatalf("窗口内 4 次命中应只告警 1 次(第 3 次触发后冷却),got %d 条: %v", len(alerts), alerts)
}
if !strings.Contains(alerts[0], "3 次") {
t.Errorf("告警文案应含累计次数,got %q", alerts[0])
}
}
// TestAlertRuleBadDataDoesNotBlockParse 验证规则表异常不影响解析主流程。
func TestAlertRuleBadDataDoesNotBlockParse(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
ctx := context.Background()
// 直插一条绕过校验的坏规则(IP 列表非法)
bad := model.AlertRule{Name: "bad", Enabled: true, Threshold: 1, SourceIPs: "not-an-ip"}
if err := db.Create(&bad).Error; err != nil {
t.Fatalf("insert bad rule: %v", err)
}
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "1.2.3.4"))
svc.parseOnce(ctx)
var e model.LogEvent
if err := db.First(&e, "message_id = ?", "m1").Error; err != nil {
t.Fatalf("find event: %v", err)
}
if !e.Processed {
t.Fatal("坏规则不应阻塞事件解析")
}
}
// mustIngest 落一条回传事件,失败即终止测试。
func mustIngest(t *testing.T, svc *LogEventService, msgID, payload string) {
t.Helper()
if err := svc.Ingest(context.Background(), 1, msgID, []byte(payload), false); err != nil {
t.Fatalf("ingest %s: %v", msgID, err)
}
}
// TestCleanupAlertHits 验证过期命中记录随清理删除。
func TestCleanupAlertHits(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
old := model.AlertRuleHit{RuleID: 1, HitAt: time.Now().Add(-8 * 24 * time.Hour)}
fresh := model.AlertRuleHit{RuleID: 1, HitAt: time.Now()}
if err := db.Create(&old).Error; err != nil {
t.Fatalf("insert: %v", err)
}
if err := db.Create(&fresh).Error; err != nil {
t.Fatalf("insert: %v", err)
}
svc.cleanupAlertHits(context.Background())
var count int64
db.Model(&model.AlertRuleHit{}).Count(&count)
if count != 1 {
t.Fatalf("清理后应剩 1 条,got %d", count)
}
}
func TestClearAlertCooldown(t *testing.T) {
svc := NewLogEventService(nil)
rule1 := model.AlertRule{ID: 1, Threshold: 2, WindowMinutes: 10}
rule2 := model.AlertRule{ID: 2, Threshold: 2, WindowMinutes: 10}
if !svc.alertCooldownPass(rule1) || !svc.alertCooldownPass(rule2) {
t.Fatal("首次命中应通过冷却检查")
}
svc.ClearAlertCooldown([]uint{rule1.ID})
if !svc.alertCooldownPass(rule1) {
t.Fatal("已清理规则应重新通过冷却检查")
}
if svc.alertCooldownPass(rule2) {
t.Fatal("未清理规则不应通过冷却检查")
}
}
func TestRecordAlertHitRejectsMissingRefs(t *testing.T) {
tests := []struct {
name string
deleteRule bool
}{
{name: "规则已删除", deleteRule: true},
{name: "事件已删除"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testRecordAlertHitMissingRef(t, tt.deleteRule)
})
}
}
func testRecordAlertHitMissingRef(t *testing.T, deleteRule bool) {
t.Helper()
svc, db, cfgID := newLogEventEnv(t)
rule := model.AlertRule{Name: "r", OciConfigID: cfgID, Threshold: 2, WindowMinutes: 5}
event := model.LogEvent{OciConfigID: cfgID, MessageID: "m"}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("create rule: %v", err)
}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
var err error
if deleteRule {
err = db.Delete(&rule).Error
} else {
err = db.Delete(&event).Error
}
if err != nil {
t.Fatalf("delete reference: %v", err)
}
if count, ok := svc.recordAlertHit(context.Background(), rule, &event); ok || count != 0 {
t.Fatalf("record missing refs = (%d,%v), want (0,false)", count, ok)
}
var hits int64
db.Model(&model.AlertRuleHit{}).Count(&hits)
if hits != 0 {
t.Fatalf("orphan alert hits = %d, want 0", hits)
}
}
+411
View File
@@ -0,0 +1,411 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"oci-portal/internal/aiwire"
)
// Anthropic Messages ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
// typed chat 面剔除后 Messages 入口的唯一上游通路。语义损失(README 已披露):
// stop_sequences / top_k / metadata / thinking 无对应字段,忽略;上游 reasoning
// 输出项与增量事件丢弃(Anthropic thinking 块含签名语义,不伪造)。
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本/图片/工具块)。
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
// AnthropicToResponsesBody 把 Messages 请求转为直通 body(强制 store:false)。
func AnthropicToResponsesBody(req aiwire.MessagesRequest) ([]byte, error) {
input, err := anthInputItems(req.Messages)
if err != nil {
return nil, err
}
body := map[string]any{"model": req.Model, "max_output_tokens": req.MaxTokens,
"input": input, "store": false}
if sys := req.SystemText(); sys != "" {
body["instructions"] = sys
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
if req.TopP != nil {
body["top_p"] = *req.TopP
}
if tools := anthRespTools(req.Tools); tools != nil {
body["tools"] = tools
}
if tc := anthRespToolChoice(req.ToolChoice); tc != nil {
body["tool_choice"] = tc
}
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.OutputConfig.Effort)}
}
if req.Stream {
body["stream"] = true
}
return json.Marshal(body)
}
// anthInputItems 把消息序列展开为 Responses input 项:tool_use / tool_result 为
// 独立 function_call / function_call_output 项,其余聚合为 message 项;遇独立项时
// 先冲刷已聚合部件,保持块间相对顺序。
func anthInputItems(messages []aiwire.AnthMessage) ([]any, error) {
var items []any
for _, m := range messages {
var parts []map[string]any
flush := func() {
if len(parts) > 0 {
items = append(items, map[string]any{"role": m.Role, "content": parts})
parts = nil
}
}
for _, b := range m.Content.AllBlocks() {
item, part, err := anthBlockItem(m.Role, b)
if err != nil {
return nil, err
}
if part != nil {
parts = append(parts, part)
}
if item != nil {
flush()
items = append(items, item)
}
}
flush()
}
return items, nil
}
// anthBlockItem 把单个内容块转为独立项或消息部件(thinking 忽略,未知块拒绝)。
func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, err error) {
switch b.Type {
case "text":
return nil, anthTextPart(role, b.Text), nil
case "image":
part, err = anthImageInput(b.Source)
return nil, part, err
case "tool_use":
return map[string]any{"type": "function_call", "call_id": b.ID,
"name": b.Name, "arguments": string(b.Input)}, nil, nil
case "tool_result":
return map[string]any{"type": "function_call_output",
"call_id": b.ToolUseID, "output": b.ResultText()}, nil, nil
case "thinking", "redacted_thinking":
return nil, nil, nil
default:
return nil, nil, ErrAiUnsupportedBlock
}
}
// anthTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text)。
func anthTextPart(role, text string) map[string]any {
if role == "assistant" {
return map[string]any{"type": "output_text", "text": text}
}
return map[string]any{"type": "input_text", "text": text}
}
// anthImageInput 把 Anthropic image source 转为 Responses input_image 部件。
func anthImageInput(source json.RawMessage) (map[string]any, error) {
var src struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
URL string `json:"url"`
}
if err := json.Unmarshal(source, &src); err != nil {
return nil, fmt.Errorf("image source 解析失败: %w", err)
}
switch src.Type {
case "base64":
if src.MediaType == "" || src.Data == "" {
return nil, fmt.Errorf("image source 缺少 media_type 或 data")
}
return map[string]any{"type": "input_image",
"image_url": "data:" + src.MediaType + ";base64," + src.Data}, nil
case "url":
if src.URL == "" {
return nil, fmt.Errorf("image source 缺少 url")
}
return map[string]any{"type": "input_image", "image_url": src.URL}, nil
}
return nil, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
}
func anthRespTools(tools []aiwire.AnthTool) []map[string]any {
if len(tools) == 0 {
return nil
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{"type": "function", "name": t.Name,
"description": t.Description, "parameters": t.InputSchema})
}
return out
}
// anthRespToolChoice 映射 {type:auto|any|tool|none,name} → Responses 形态。
func anthRespToolChoice(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &tc) != nil {
return nil
}
switch tc.Type {
case "auto":
return "auto"
case "any":
return "required"
case "none":
return "none"
case "tool":
return map[string]string{"type": "function", "name": tc.Name}
}
return nil
}
// respPayload 是直通响应中本转换关心的子集(未知字段忽略)。
type respPayload struct {
Model string `json:"model"`
Status string `json:"status"`
IncompleteDetails *respIncomplete `json:"incomplete_details"`
Output []respOutputItem `json:"output"`
Usage *aiwire.RespUsage `json:"usage"`
Error *map[string]string `json:"error"`
}
type respIncomplete struct {
Reason string `json:"reason"`
}
type respOutputItem struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
// ResponsesToAnthropic 把直通非流式响应转为 Anthropic Messages 响应。
func ResponsesToAnthropic(payload []byte, msgID string) (*aiwire.MessagesResponse, error) {
var resp respPayload
if err := json.Unmarshal(payload, &resp); err != nil {
return nil, fmt.Errorf("解析上游响应: %w", err)
}
out := &aiwire.MessagesResponse{ID: msgID, Type: "message", Role: "assistant",
Model: resp.Model, Content: []aiwire.AnthBlock{}, StopReason: "end_turn"}
for _, item := range resp.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: part.Text})
}
}
case "function_call":
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: item.CallID,
Name: item.Name, Input: argsToJSON(item.Arguments)})
out.StopReason = "tool_use"
}
}
if resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
resp.IncompleteDetails.Reason == "max_output_tokens" {
out.StopReason = "max_tokens"
}
out.Usage = anthUsageFromResp(resp.Usage)
return out, nil
}
func anthUsageFromResp(u *aiwire.RespUsage) aiwire.AnthUsage {
if u == nil {
return aiwire.AnthUsage{}
}
return aiwire.AnthUsage{InputTokens: u.InputTokens, OutputTokens: u.OutputTokens,
CacheReadInputTokens: u.InputTokensDetails.CachedTokens}
}
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
func argsToJSON(args string) json.RawMessage {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(map[string]string{"_raw": args})
return b
}
// ---- Anthropic 流式桥:Responses SSE 事件 → Anthropic 事件序列 ----
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
type AnthEvent struct {
Event string
Data any
}
// AnthRespBridge 把直通 SSE 事件流桥接为 Anthropic 事件序列:
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
// reasoning 系列事件丢弃。
type AnthRespBridge struct {
id, model string
started bool
blockOpen bool
blockIsTool bool
blockIndex int
stopReason string
usage aiwire.AnthUsage
}
// NewAnthRespBridge 构造桥;id 为响应消息 ID。
func NewAnthRespBridge(id, model string) *AnthRespBridge {
return &AnthRespBridge{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
}
// respStreamEvent 是直通 SSE data JSON 中桥关心的子集。
type respStreamEvent struct {
Type string `json:"type"`
Delta string `json:"delta"`
Item *respOutputItem `json:"item"`
Response *respPayload `json:"response"`
}
// Feed 消费一行 SSE data JSON,返回应立即写出的事件。
func (st *AnthRespBridge) Feed(data []byte) []AnthEvent {
var ev respStreamEvent
if json.Unmarshal(data, &ev) != nil {
return nil
}
var events []AnthEvent
if !st.started {
st.started = true
events = append(events, st.startEvent())
}
switch ev.Type {
case "response.output_item.added":
if ev.Item != nil && ev.Item.Type == "function_call" {
events = append(events, st.openBlock(true, ev.Item.CallID, ev.Item.Name)...)
st.stopReason = "tool_use"
}
case "response.output_text.delta":
events = append(events, st.textDelta(ev.Delta)...)
case "response.function_call_arguments.delta":
events = append(events, st.argsDelta(ev.Delta)...)
case "response.completed", "response.incomplete", "response.failed":
st.finishFrom(ev.Response)
}
return events
}
func (st *AnthRespBridge) textDelta(delta string) []AnthEvent {
var events []AnthEvent
if !st.blockOpen || st.blockIsTool {
events = append(events, st.openBlock(false, "", "")...)
}
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "text_delta", "text": delta},
}})
return events
}
func (st *AnthRespBridge) argsDelta(delta string) []AnthEvent {
if !st.blockOpen || !st.blockIsTool {
return nil
}
return []AnthEvent{{Event: "content_block_delta", Data: map[string]any{
"type": "content_block_delta", "index": st.blockIndex,
"delta": map[string]string{"type": "input_json_delta", "partial_json": delta},
}}}
}
// finishFrom 记录终态:usage 与 stop_reason(max_output_tokens 截断 → max_tokens)。
func (st *AnthRespBridge) finishFrom(resp *respPayload) {
if resp == nil {
return
}
st.usage = anthUsageFromResp(resp.Usage)
if resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
resp.IncompleteDetails.Reason == "max_output_tokens" {
st.stopReason = "max_tokens"
}
}
func (st *AnthRespBridge) startEvent() AnthEvent {
return AnthEvent{Event: "message_start", Data: map[string]any{
"type": "message_start",
"message": map[string]any{
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
"content": []any{}, "stop_reason": nil,
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
},
}}
}
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
func (st *AnthRespBridge) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
var events []AnthEvent
if st.blockOpen {
events = append(events, st.closeBlockEvent())
}
st.blockOpen, st.blockIsTool = true, isTool
st.blockIndex++
block := map[string]any{"type": "text", "text": ""}
if isTool {
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
}
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
}})
return events
}
func (st *AnthRespBridge) closeBlockEvent() AnthEvent {
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
"type": "content_block_stop", "index": st.blockIndex,
}}
}
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
func (st *AnthRespBridge) Finish() []AnthEvent {
var events []AnthEvent
if !st.started {
st.started = true
events = append(events, st.startEvent())
}
if st.blockOpen {
events = append(events, st.closeBlockEvent())
st.blockOpen = false
}
usage := map[string]int{"output_tokens": st.usage.OutputTokens}
if st.usage.InputTokens > 0 {
usage["input_tokens"] = st.usage.InputTokens
}
if st.usage.CacheReadInputTokens > 0 {
usage["cache_read_input_tokens"] = st.usage.CacheReadInputTokens
}
events = append(events,
AnthEvent{Event: "message_delta", Data: map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
"usage": usage,
}},
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
)
return events
}
// Usage 返回聚合到的用量(供调用日志)。
func (st *AnthRespBridge) Usage() aiwire.AnthUsage { return st.usage }
+173
View File
@@ -0,0 +1,173 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func mustAnthReq(t *testing.T, raw string) aiwire.MessagesRequest {
t.Helper()
var req aiwire.MessagesRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("解析请求: %v", err)
}
return req
}
// TestAnthropicToResponsesBody 断言 system/消息/工具/effort 的直通装配与 store 强制。
func TestAnthropicToResponsesBody(t *testing.T) {
req := mustAnthReq(t, `{
"model": "xai.grok-4.3", "max_tokens": 128, "system": "你是助手", "stream": true,
"temperature": 0.5,
"messages": [
{"role": "user", "content": "东京天气?"},
{"role": "assistant", "content": [
{"type": "text", "text": "查询中"},
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
{"type": "text", "text": "继续"}
]}
],
"tools": [{"name": "get_weather", "description": "查天气", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "any"},
"output_config": {"effort": "HIGH"}
}`)
payload, err := AnthropicToResponsesBody(req)
if err != nil {
t.Fatalf("AnthropicToResponsesBody: %v", err)
}
var body map[string]any
if err := json.Unmarshal(payload, &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body["model"] != "xai.grok-4.3" || body["max_output_tokens"] != float64(128) ||
body["instructions"] != "你是助手" || body["store"] != false || body["stream"] != true {
t.Fatalf("顶层字段装配错误: %v", body)
}
if body["tool_choice"] != "required" {
t.Fatalf("tool_choice = %v, want required", body["tool_choice"])
}
reasoning, _ := body["reasoning"].(map[string]any)
if reasoning["effort"] != "high" {
t.Fatalf("effort = %v, want high(小写透传)", reasoning)
}
input, _ := body["input"].([]any)
// user 消息、assistant 文本消息、function_call、function_call_output、末条 user 文本
if len(input) != 5 {
t.Fatalf("input 项数 = %d, want 5: %s", len(input), payload)
}
kinds := make([]string, 0, len(input))
for _, it := range input {
m := it.(map[string]any)
if ty, ok := m["type"].(string); ok {
kinds = append(kinds, ty)
} else {
kinds = append(kinds, "message/"+m["role"].(string))
}
}
want := "message/user,message/assistant,function_call,function_call_output,message/user"
if got := strings.Join(kinds, ","); got != want {
t.Fatalf("input 顺序 = %s, want %s", got, want)
}
if !strings.Contains(string(payload), `"output_text"`) {
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", payload)
}
}
// TestAnthropicToResponsesBodyRejects 断言不支持的内容块拒绝与图片装配。
func TestAnthropicToResponsesBodyRejects(t *testing.T) {
bad := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
{"role":"user","content":[{"type":"document","source":{}}]}]}`)
if _, err := AnthropicToResponsesBody(bad); err == nil {
t.Fatal("document 块应拒绝")
}
img := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJD"}}]}]}`)
payload, err := AnthropicToResponsesBody(img)
if err != nil || !strings.Contains(string(payload), "data:image/png;base64,QUJD") {
t.Fatalf("图片应转 data URI: %v %s", err, payload)
}
}
// TestResponsesToAnthropic 断言输出块、stop_reason 与 usage 的回转。
func TestResponsesToAnthropic(t *testing.T) {
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
{"type":"reasoning","summary":[]},
{"type":"message","content":[{"type":"output_text","text":"你好"}]},
{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}],
"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`)
out, err := ResponsesToAnthropic(payload, "msg_1")
if err != nil {
t.Fatalf("ResponsesToAnthropic: %v", err)
}
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[0].Text != "你好" {
t.Fatalf("content 装配错误: %+v", out.Content)
}
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "c1" || string(out.Content[1].Input) != `{"city":"东京"}` {
t.Fatalf("tool_use 装配错误: %+v", out.Content[1])
}
if out.StopReason != "tool_use" {
t.Fatalf("stop_reason = %s, want tool_use", out.StopReason)
}
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Usage.CacheReadInputTokens != 4 {
t.Fatalf("usage = %+v", out.Usage)
}
trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},
"output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`)
out2, err := ResponsesToAnthropic(trunc, "msg_2")
if err != nil || out2.StopReason != "max_tokens" {
t.Fatalf("截断 stop_reason = %v, %v", out2, err)
}
}
func bridgeEventTypes(events []AnthEvent) string {
kinds := make([]string, 0, len(events))
for _, ev := range events {
kinds = append(kinds, ev.Event)
}
return strings.Join(kinds, ",")
}
// TestAnthRespBridge 断言流桥:文本增量、工具调用切块、reasoning 丢弃与收尾事件。
func TestAnthRespBridge(t *testing.T) {
st := NewAnthRespBridge("msg_1", "m1")
var events []AnthEvent
feed := func(lines ...string) {
for _, l := range lines {
events = append(events, st.Feed([]byte(l))...)
}
}
feed(`{"type":"response.created","response":{"model":"m1"}}`,
`{"type":"response.reasoning_text.delta","delta":"思考中"}`,
`{"type":"response.output_text.delta","delta":"你"}`,
`{"type":"response.output_text.delta","delta":"好"}`,
`{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`,
`{"type":"response.function_call_arguments.delta","delta":"{\"a\":1}"}`,
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4}}}`)
events = append(events, st.Finish()...)
got := bridgeEventTypes(events)
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
"content_block_stop,content_block_start,content_block_delta,content_block_stop," +
"message_delta,message_stop"
if got != want {
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
}
if st.Usage().InputTokens != 8 || st.Usage().OutputTokens != 4 {
t.Fatalf("usage = %+v", st.Usage())
}
}
// TestAnthRespBridgeEmpty 断言空流也产出完整事件骨架。
func TestAnthRespBridgeEmpty(t *testing.T) {
st := NewAnthRespBridge("msg_1", "m1")
if got := bridgeEventTypes(st.Finish()); got != "message_start,message_delta,message_stop" {
t.Errorf("空流事件序列 = %s", got)
}
}
+65 -50
View File
@@ -2,17 +2,19 @@ package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"time"
"oci-portal/internal/oci"
)
// 审计查询时间窗(小时)上下限
// 批式懒加载的单批条数上下限与缺省值
const (
minAuditHours = 1
maxAuditHours = 72
auditLimitDefault = 100
auditLimitMax = 200
)
// 审计原始事件缓存:约 3KB/条,上限 2000 条 ≈ 6MB;TTL 内详情秒开。
@@ -21,39 +23,32 @@ const (
auditRawTTL = 10 * time.Minute
)
// ErrInvalidAuditHours 表示审计时间窗参数越界,handler 据此返回 400。
var ErrInvalidAuditHours = errors.New("audit events: hours must be between 1 and 72")
// ErrInvalidAuditWindow 表示续查的绝对时间窗非法(格式/顺序/跨度),handler 返回 400。
var ErrInvalidAuditWindow = errors.New("audit events: invalid start/end window")
// ErrInvalidAuditCursor 表示续查游标不可解析(过期格式/被篡改),handler 返回 400。
var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to restart")
// ErrAuditEventGone 表示原始事件缓存过期且小窗重查未找回;handler 映射 404。
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
// AuditQuery 是审计查询参数:Hours 为首查(相对当前时刻);
// Start/End(RFC3339)为续查绝对窗,配合 Page 从截断游标断点续翻
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数
type AuditQuery struct {
Region string
Hours int
Start string
End string
Page string
Cursor string
Limit int
}
// AuditEventsView 是审计查询响应:列表不含 raw(详情接口取回);
// Start/End 回传本次实际使用的绝对窗,截断续查必须原样带回(游标绑定查询参数)
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头
type AuditEventsView struct {
Items []oci.AuditEvent `json:"items"`
Truncated bool `json:"truncated"`
NextPage string `json:"nextPage,omitempty"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Cursor string `json:"cursor,omitempty"`
Exhausted bool `json:"exhausted"`
}
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
// 默认区域。原始事件剥离进进程内缓存,响应体瘦身约 10 倍(调研④方案 A1+B1)。
func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuery) (AuditEventsView, error) {
start, end, err := auditWindow(q)
cur, err := decodeAuditCursor(q.Cursor)
if err != nil {
return AuditEventsView{}, err
}
@@ -61,53 +56,73 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
if err != nil {
return AuditEventsView{}, err
}
res, err := s.client.ListAuditEvents(ctx, cred, q.Region, start, end, q.Page)
res, err := s.client.ListAuditEventsBatch(ctx, cred, q.Region, cur, auditLimit(q.Limit))
if err != nil {
return AuditEventsView{}, err
}
return AuditEventsView{
Items: s.stripAuditRaw(res.Items),
Truncated: res.Truncated,
NextPage: res.NextPage,
// 与实际请求同粒度(分钟),续查回传时窗口逐字节一致
Start: start.UTC().Truncate(time.Minute),
End: end.UTC().Truncate(time.Minute),
}, nil
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
if res.Cursor != nil {
view.Cursor = encodeAuditCursor(*res.Cursor)
}
return view, nil
}
// auditWindow 解析查询窗口:带 start/end/page 走绝对窗校验,否则按 hours 相对窗
func auditWindow(q AuditQuery) (time.Time, time.Time, error) {
if q.Start != "" || q.End != "" || q.Page != "" {
start, err1 := time.Parse(time.RFC3339, q.Start)
end, err2 := time.Parse(time.RFC3339, q.End)
if err1 != nil || err2 != nil || !start.Before(end) ||
end.Sub(start) > maxAuditHours*time.Hour+time.Minute {
return time.Time{}, time.Time{}, ErrInvalidAuditWindow
}
return start, end, nil
// auditLimit 归一单批条数:缺省 100,上限 200(响应体量与页预算的平衡)
func auditLimit(limit int) int {
if limit <= 0 {
return auditLimitDefault
}
if q.Hours < minAuditHours || q.Hours > maxAuditHours {
return time.Time{}, time.Time{}, ErrInvalidAuditHours
}
end := time.Now().UTC()
return end.Add(-time.Duration(q.Hours) * time.Hour), end, nil
return min(limit, auditLimitMax)
}
// stripAuditRaw 把每条原始事件按 eventId 放进缓存并从列表剥离
func (s *OciConfigService) stripAuditRaw(items []oci.AuditEvent) []oci.AuditEvent {
// encodeAuditCursor 把游标序列化为不透明字符串(base64url JSON)
// 内容仅时间窗与 OCI 翻页令牌,伪造只影响自己的查询范围,无需签名。
func encodeAuditCursor(cur oci.AuditCursor) string {
b, _ := json.Marshal(cur)
return base64.RawURLEncoding.EncodeToString(b)
}
// decodeAuditCursor 解析续查游标;空串返回自当前时刻起的首查游标。
func decodeAuditCursor(s string) (oci.AuditCursor, error) {
if s == "" {
return oci.NewAuditCursor(time.Now()), nil
}
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return oci.AuditCursor{}, ErrInvalidAuditCursor
}
var cur oci.AuditCursor
if err := json.Unmarshal(b, &cur); err != nil || cur.Start.IsZero() || !cur.Start.Before(cur.End) {
return oci.AuditCursor{}, ErrInvalidAuditCursor
}
return cur, nil
}
// auditRawKey 组装租户隔离的原始事件缓存键。
func auditRawKey(configID uint, eventID string) string {
return strconv.FormatUint(uint64(configID), 10) + ":" + eventID
}
// stripAuditRaw 把每条原始事件按租户与 eventId 放进缓存并从列表剥离。
func (s *OciConfigService) stripAuditRaw(configID uint, items []oci.AuditEvent) []oci.AuditEvent {
for i := range items {
if items[i].EventId != "" && items[i].Raw != nil {
s.auditRaw.Set(items[i].EventId, items[i].Raw, auditRawTTL)
s.auditRaw.Set(auditRawKey(configID, items[i].EventId), items[i].Raw, auditRawTTL)
}
items[i].Raw = nil
}
return items
}
// InvalidateAuditCache 删除指定租户的全部审计原始事件缓存。
func (s *OciConfigService) InvalidateAuditCache(configID uint) {
s.auditRaw.DeletePrefix(strconv.FormatUint(uint64(configID), 10) + ":")
}
// AuditEventDetail 取回单条事件的原始 JSON:缓存命中即回;miss 按事件时间
// 所在分钟起 2 分钟小窗重查兜底(调研④方案 A2),仍未命中报 ErrAuditEventGone。
func (s *OciConfigService) AuditEventDetail(ctx context.Context, id uint, region, eventID string, eventTime time.Time) (json.RawMessage, error) {
if raw, ok := s.auditRaw.Get(eventID); ok {
if raw, ok := s.auditRaw.Get(auditRawKey(id, eventID)); ok {
return raw.(json.RawMessage), nil
}
cred, err := s.credentialsByID(ctx, id)
@@ -124,7 +139,7 @@ func (s *OciConfigService) AuditEventDetail(ctx context.Context, id uint, region
if ev.EventId == "" || ev.Raw == nil {
continue
}
s.auditRaw.Set(ev.EventId, ev.Raw, auditRawTTL)
s.auditRaw.Set(auditRawKey(id, ev.EventId), ev.Raw, auditRawTTL)
if ev.EventId == eventID {
found = ev.Raw
}
+111 -78
View File
@@ -13,115 +13,124 @@ import (
// auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。
type auditStubClient struct {
*fakeClient
batch oci.AuditBatchResult
result oci.AuditEventsResult
gotRegion string
gotCursor oci.AuditCursor
gotLimit int
gotStart time.Time
gotEnd time.Time
gotPage string
calls int
}
func (f *auditStubClient) ListAuditEventsBatch(ctx context.Context, cred oci.Credentials, region string, cur oci.AuditCursor, limit int) (oci.AuditBatchResult, error) {
f.calls++
f.gotRegion, f.gotCursor, f.gotLimit = region, cur, limit
return f.batch, nil
}
func (f *auditStubClient) ListAuditEvents(ctx context.Context, cred oci.Credentials, region string, start, end time.Time, page string) (oci.AuditEventsResult, error) {
f.calls++
f.gotRegion, f.gotStart, f.gotEnd, f.gotPage = region, start, end, page
f.gotRegion, f.gotStart, f.gotEnd = region, start, end
return f.result, nil
}
func TestAuditEventsHoursValidation(t *testing.T) {
tests := []struct {
name string
hours int
wantErr bool
}{
{name: "下界 1 小时", hours: 1},
{name: "默认 24 小时", hours: 24},
{name: "上界 72 小时", hours: 72},
{name: "0 越界", hours: 0, wantErr: true},
{name: "负数越界", hours: -3, wantErr: true},
{name: "100 越界", hours: 100, wantErr: true},
func TestAuditCursorCodec(t *testing.T) {
cur := oci.AuditCursor{
Start: time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC),
Page: "tok-1",
WindowHours: 48,
}
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{{EventName: "GetInstance"}}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Hours: tt.hours})
if tt.wantErr {
if !errors.Is(err, ErrInvalidAuditHours) {
t.Fatalf("AuditEvents(hours=%d) error = %v, want ErrInvalidAuditHours", tt.hours, err)
}
return
}
if err != nil {
t.Fatalf("AuditEvents(hours=%d): %v", tt.hours, err)
}
if len(got.Items) != 1 {
t.Errorf("items = %d, want 1", len(got.Items))
}
})
}
}
func TestAuditEventsWindowAndRegion(t *testing.T) {
client := &auditStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Region: "ap-tokyo-1", Hours: 6})
got, err := decodeAuditCursor(encodeAuditCursor(cur))
if err != nil {
t.Fatalf("AuditEvents: %v", err)
t.Fatalf("roundtrip: %v", err)
}
if client.calls != 1 {
t.Fatalf("calls = %d, want 1", client.calls)
if !got.Start.Equal(cur.Start) || !got.End.Equal(cur.End) || got.Page != cur.Page || got.WindowHours != cur.WindowHours {
t.Fatalf("roundtrip = %+v, want %+v", got, cur)
}
if client.gotRegion != "ap-tokyo-1" {
t.Errorf("region = %q, want %q", client.gotRegion, "ap-tokyo-1")
// 空串 → 自当前时刻首查:24h 窗、分钟粒度、无窗内游标
first, err := decodeAuditCursor("")
if err != nil {
t.Fatalf("first cursor: %v", err)
}
if window := client.gotEnd.Sub(client.gotStart); window != 6*time.Hour {
t.Errorf("window = %v, want %v", window, 6*time.Hour)
if first.End.Sub(first.Start) != 24*time.Hour || first.Page != "" || first.End.Second() != 0 {
t.Fatalf("first cursor = %+v, 应为 24h 分钟粒度首窗", first)
}
// 响应回传分钟粒度的绝对窗,续查据此原样带回
if got.Start.Second() != 0 || !got.End.After(got.Start) {
t.Errorf("响应窗口 = [%v, %v), 应为分钟粒度且有序", got.Start, got.End)
bad := []string{"!!!", "bm90LWpzb24", encodeAuditCursor(oci.AuditCursor{})}
for i, s := range bad {
if _, err := decodeAuditCursor(s); !errors.Is(err, ErrInvalidAuditCursor) {
t.Errorf("bad[%d] err = %v, want ErrInvalidAuditCursor", i, err)
}
}
}
func TestAuditEventsResume(t *testing.T) {
func TestAuditEventsBatchParams(t *testing.T) {
next := oci.AuditCursor{
Start: time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC),
WindowHours: 24,
}
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{}, Truncated: true, NextPage: "tok-2"},
batch: oci.AuditBatchResult{
Items: []oci.AuditEvent{{EventId: "e1", EventName: "GetInstance"}},
Cursor: &next,
},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 续查:绝对窗 + 游标透传;NextPage 原样回
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{
Start: "2026-07-08T10:00:00Z", End: "2026-07-09T10:00:00Z", Page: "tok-1",
})
// limit 归一:0 → 100;超上限截到 200;region 透
cases := []struct {
name string
limit int
wantLimit int
}{
{"缺省 100", 0, 100},
{"正常透传", 50, 50},
{"超限截断", 999, 200},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Region: "ap-tokyo-1", Limit: tc.limit})
if err != nil {
t.Fatalf("AuditEvents: %v", err)
}
if client.gotLimit != tc.wantLimit || client.gotRegion != "ap-tokyo-1" {
t.Fatalf("limit = %d(want %d), region = %q", client.gotLimit, tc.wantLimit, client.gotRegion)
}
if got.Cursor == "" || got.Exhausted {
t.Fatalf("响应应携带续查游标: %+v", got)
}
})
}
// 续查:响应游标原样带回可解析,并透传到 oci 层
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{})
if err != nil {
t.Fatalf("首查: %v", err)
}
if _, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Cursor: got.Cursor}); err != nil {
t.Fatalf("续查: %v", err)
}
if client.gotPage != "tok-1" || !got.Truncated || got.NextPage != "tok-2" {
t.Errorf("游标透传 page=%q next=%q truncated=%v", client.gotPage, got.NextPage, got.Truncated)
if !client.gotCursor.Start.Equal(next.Start) || client.gotCursor.WindowHours != 24 {
t.Fatalf("续查游标透传 = %+v, want %+v", client.gotCursor, next)
}
if !client.gotStart.Equal(time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)) {
t.Errorf("续查未用绝对窗: start=%v", client.gotStart)
// 尽头:Cursor 为 nil → 响应空游标 + exhausted
client.batch = oci.AuditBatchResult{Items: []oci.AuditEvent{}, Exhausted: true}
end, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{})
if err != nil || end.Cursor != "" || !end.Exhausted {
t.Fatalf("尽头响应 = %+v, %v", end, err)
}
// 非法窗口逐项拒绝
bad := []AuditQuery{
{Start: "not-a-time", End: "2026-07-09T10:00:00Z"},
{Start: "2026-07-09T10:00:00Z", End: "2026-07-08T10:00:00Z"}, // 倒序
{Start: "2026-07-01T00:00:00Z", End: "2026-07-09T10:00:00Z"}, // 超 72h
{Page: "tok-only"}, // 带游标缺窗口
}
for i, q := range bad {
if _, err := svc.AuditEvents(ctx, cfg.ID, q); !errors.Is(err, ErrInvalidAuditWindow) {
t.Errorf("bad[%d] err = %v, want ErrInvalidAuditWindow", i, err)
}
// 非法游标 → ErrInvalidAuditCursor
if _, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Cursor: "!!!"}); !errors.Is(err, ErrInvalidAuditCursor) {
t.Fatalf("非法游标 err = %v", err)
}
}
@@ -130,7 +139,7 @@ func TestAuditRawStrippedAndDetail(t *testing.T) {
eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC)
client := &auditStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
result: oci.AuditEventsResult{Items: []oci.AuditEvent{
batch: oci.AuditBatchResult{Items: []oci.AuditEvent{
{EventId: "evt-1", EventTime: &eventTime, EventName: "TerminateInstance", Raw: raw},
}},
}
@@ -138,7 +147,7 @@ func TestAuditRawStrippedAndDetail(t *testing.T) {
cfg := importAliveConfig(t, svc)
ctx := context.Background()
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Hours: 1})
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{})
if err != nil {
t.Fatalf("AuditEvents: %v", err)
}
@@ -184,3 +193,27 @@ func TestAuditDetailRequeryFallback(t *testing.T) {
t.Errorf("未找回 err = %v, want ErrAuditEventGone", err)
}
}
func TestAuditRawCacheTenantIsolationAndInvalidation(t *testing.T) {
svc := newTestService(t, &fakeClient{})
raw1 := json.RawMessage(`{"tenant":1}`)
raw2 := json.RawMessage(`{"tenant":2}`)
svc.stripAuditRaw(1, []oci.AuditEvent{{EventId: "same", Raw: raw1}})
svc.stripAuditRaw(2, []oci.AuditEvent{{EventId: "same", Raw: raw2}})
assertAuditRaw(t, svc, 1, raw1)
assertAuditRaw(t, svc, 2, raw2)
svc.InvalidateAuditCache(1)
if _, ok := svc.auditRaw.Get(auditRawKey(1, "same")); ok {
t.Fatal("tenant 1 raw cache still exists after invalidation")
}
assertAuditRaw(t, svc, 2, raw2)
}
func assertAuditRaw(t *testing.T, svc *OciConfigService, configID uint, want json.RawMessage) {
t.Helper()
got, ok := svc.auditRaw.Get(auditRawKey(configID, "same"))
if !ok || string(got.(json.RawMessage)) != string(want) {
t.Fatalf("tenant %d raw = %v, want %s", configID, got, want)
}
}
+58 -12
View File
@@ -28,6 +28,13 @@ var ErrLoginLocked = errors.New("too many failed attempts, try again later")
// tokenTTL 是登录令牌有效期。
const tokenTTL = 24 * time.Hour
// authClaims 在标准声明外携带令牌版本;版本落后于账号当前值即失效。
// 存量令牌无 ver 字段解析为 0,与存量账号的零值版本兼容(升级不强制登出)。
type authClaims struct {
jwt.RegisteredClaims
Ver uint `json:"ver"`
}
// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"),
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
@@ -106,7 +113,7 @@ func (s *AuthService) createUser(username, password string) error {
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
key := clientIP + "|" + username
key := guardKey(clientIP, username)
now := time.Now()
sec := securityOf(s.settings)
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
@@ -138,7 +145,7 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
}
}
s.guard.success(key)
return s.signToken(user.Username)
return s.signToken(user.Username, user.TokenVersion)
}
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
@@ -169,15 +176,18 @@ func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings
})
}
func (s *AuthService) signToken(username string) (string, time.Time, error) {
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
now := time.Now()
expires := now.Add(tokenTTL)
claims := jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
claims := authClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
},
Ver: ver,
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
if err != nil {
@@ -186,6 +196,34 @@ func (s *AuthService) signToken(username string) (string, time.Time, error) {
return token, expires, nil
}
// IssueToken 按账号当前令牌版本签发新 JWT;敏感操作递增版本后用它为
// 操作者重签,避免操作者自身会话中断。
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
user, err := s.findUser(ctx, username)
if err != nil {
return "", time.Time{}, err
}
return s.signToken(user.Username, user.TokenVersion)
}
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", username).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
if err != nil {
return fmt.Errorf("bump token version: %w", err)
}
return nil
}
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌。
func (s *AuthService) RevokeSessions(ctx context.Context, username string) (string, time.Time, error) {
if err := s.bumpTokenVersion(ctx, username); err != nil {
return "", time.Time{}, err
}
return s.IssueToken(ctx, username)
}
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
func newTokenID() string {
b := make([]byte, 16)
@@ -193,9 +231,10 @@ func newTokenID() string {
return hex.EncodeToString(b)
}
// ParseToken 验证 JWT 签名有效期返回其中的用户名。
func (s *AuthService) ParseToken(tokenString string) (string, error) {
claims := &jwt.RegisteredClaims{}
// ParseToken 验证 JWT 签名有效期与令牌版本,返回其中的用户名。
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
claims := &authClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
@@ -208,6 +247,13 @@ func (s *AuthService) ParseToken(tokenString string) (string, error) {
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
return "", errors.New("token revoked")
}
user, err := s.findUser(ctx, claims.Subject)
if err != nil {
return "", fmt.Errorf("token subject: %w", err)
}
if claims.Ver != user.TokenVersion {
return "", errors.New("token version outdated")
}
return claims.Subject, nil
}
+65 -8
View File
@@ -109,7 +109,7 @@ func TestTokenRoundTrip(t *testing.T) {
if expires.IsZero() {
t.Error("expires is zero, want future time")
}
username, err := auth.ParseToken(token)
username, err := auth.ParseToken(context.Background(), token)
if err != nil {
t.Fatalf("ParseToken: %v", err)
}
@@ -129,35 +129,92 @@ func TestParseTokenRejectsForged(t *testing.T) {
if err != nil {
t.Fatalf("Login: %v", err)
}
if _, err := auth.ParseToken(forged); err == nil {
if _, err := auth.ParseToken(context.Background(), forged); err == nil {
t.Error("ParseToken(forged): got nil error, want failure")
}
if _, err := auth.ParseToken("not.a.token"); err == nil {
if _, err := auth.ParseToken(context.Background(), "not.a.token"); err == nil {
t.Error("ParseToken(garbage): got nil error, want failure")
}
}
func TestLogoutRevokesToken(t *testing.T) {
auth := newTestAuth(t)
token, _, err := auth.signToken("admin")
// ParseToken 现校验令牌版本,须存在对应账号
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
token, _, err := auth.signToken("admin", 0)
if err != nil {
t.Fatalf("signToken: %v", err)
}
if _, err := auth.ParseToken(token); err != nil {
if _, err := auth.ParseToken(context.Background(), token); err != nil {
t.Fatalf("ParseToken before logout: %v", err)
}
auth.Logout(token)
if _, err := auth.ParseToken(token); err == nil {
if _, err := auth.ParseToken(context.Background(), token); err == nil {
t.Error("ParseToken after logout: got nil error, want revoked")
}
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
auth.Logout(token)
auth.Logout("not.a.token")
fresh, _, err := auth.signToken("admin")
fresh, _, err := auth.signToken("admin", 0)
if err != nil {
t.Fatalf("signToken fresh: %v", err)
}
if _, err := auth.ParseToken(fresh); err != nil {
if _, err := auth.ParseToken(context.Background(), fresh); err != nil {
t.Errorf("ParseToken(fresh) after revoking old: %v", err)
}
}
// TestTokenVersionInvalidatesOldToken 凭据变更递增令牌版本,旧 JWT 立即失效,
// 重签的新令牌可用(审计 S-02 动态复现序列的反向断言)。
func TestTokenVersionInvalidatesOldToken(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewPassword: "changed-456", CurrentPassword: "pass123",
})
if err != nil {
t.Fatalf("UpdateCredentials: %v", err)
}
if _, err := auth.ParseToken(ctx, old); err == nil {
t.Error("旧 token 在凭据变更后仍有效, want 失效")
}
fresh, _, err := auth.IssueToken(ctx, finalName)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if name, err := auth.ParseToken(ctx, fresh); err != nil || name != "admin" {
t.Errorf("重签 token 应有效: name=%q err=%v", name, err)
}
}
// TestRevokeSessions 撤销全部会话:旧 token 失效,返回的新 token 有效。
func TestRevokeSessions(t *testing.T) {
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
fresh, _, err := auth.RevokeSessions(ctx, "admin")
if err != nil {
t.Fatalf("RevokeSessions: %v", err)
}
if _, err := auth.ParseToken(ctx, old); err == nil {
t.Error("撤销后旧 token 仍有效, want 失效")
}
if name, err := auth.ParseToken(ctx, fresh); err != nil || name != "admin" {
t.Errorf("撤销后新 token 应有效: name=%q err=%v", name, err)
}
}
+34 -16
View File
@@ -7,6 +7,7 @@ import (
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"oci-portal/internal/model"
)
@@ -33,42 +34,55 @@ type UpdateCredentialsInput struct {
CurrentPassword string `json:"currentPassword" binding:"required"`
}
// UpdateCredentials 修改用户名 / 密码:当前密码必验;改名后旧 JWT 的
// sub 不再命中账号,前端应强制重新登录
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) error {
// UpdateCredentials 修改用户名 / 密码:当前密码必验;成功后令牌版本递增
// (全部旧 JWT 立即失效),返回最终用户名供调用方为操作者重签新令牌
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) (string, error) {
user, err := s.findUser(ctx, username)
if err != nil {
return err
return "", err
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil {
return ErrCredentialConfirm
return "", ErrCredentialConfirm
}
newName := strings.TrimSpace(in.NewUsername)
if err := validateCredentialChange(user, newName, in.NewPassword); err != nil {
return err
return "", err
}
updates, finalName, err := s.credentialUpdates(ctx, user, newName, in.NewPassword)
if err != nil {
return "", err
}
// 同一条 UPDATE 里递增令牌版本,与凭据变更保持原子
updates["token_version"] = gorm.Expr("token_version + 1")
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return "", fmt.Errorf("update credentials: %w", err)
}
return finalName, nil
}
// credentialUpdates 组装凭据变更字段并返回最终用户名。
func (s *AuthService) credentialUpdates(ctx context.Context, user *model.User, newName, newPassword string) (map[string]any, string, error) {
updates := map[string]any{}
finalName := user.Username
if newName != "" && newName != user.Username {
taken, err := s.usernameTaken(ctx, newName, user.ID)
if err != nil {
return err
return nil, "", err
}
if taken {
return fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
return nil, "", fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
}
updates["username"] = newName
finalName = newName
}
if in.NewPassword != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
if newPassword != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
return nil, "", fmt.Errorf("hash password: %w", err)
}
updates["password_hash"] = string(hash)
}
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("update credentials: %w", err)
}
return nil
return updates, finalName, nil
}
// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。
@@ -122,7 +136,11 @@ func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username str
return ErrNeedIdentity
}
}
return s.settings.SetPasswordLoginDisabled(ctx, disabled)
if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil {
return err
}
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
return s.bumpTokenVersion(ctx, username)
}
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
+4 -4
View File
@@ -26,22 +26,22 @@ func TestUpdateCredentials(t *testing.T) {
ctx := context.Background()
// 当前密码错误
err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"})
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"})
if !errors.Is(err, ErrCredentialConfirm) {
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
}
// 短密码 / 无变更均拒绝
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"})
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"})
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
}
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"})
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"})
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
}
// 同时改名改密
err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
})
if err != nil {
+23 -23
View File
@@ -9,17 +9,17 @@ import (
)
// IdentityProviders 列出域内 SAML 身份提供者。
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint) ([]oci.IdentityProviderInfo, error) {
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListIdentityProviders(ctx, cred, homeRegion)
return s.client.ListIdentityProviders(ctx, cred, homeRegion, domainID)
}
// CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。
// 映射与 JIT 字段缺省时填控制台默认值(normalizeIdpInput)。
func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, domainID string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
if strings.TrimSpace(in.Name) == "" {
return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: name is required")
}
@@ -34,7 +34,7 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, normalizeIdpInput(in))
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
}
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
@@ -50,30 +50,30 @@ func normalizeIdpInput(in oci.CreateIdpInput) oci.CreateIdpInput {
}
// SetIdentityProviderEnabled 激活/停用 IdP 并同步登录页显示。
func (s *OciConfigService) SetIdentityProviderEnabled(ctx context.Context, id uint, idpID string, enabled bool) (oci.IdentityProviderInfo, error) {
func (s *OciConfigService) SetIdentityProviderEnabled(ctx context.Context, id uint, domainID, idpID string, enabled bool) (oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.SetIdentityProviderEnabled(ctx, cred, homeRegion, idpID, enabled)
return s.client.SetIdentityProviderEnabled(ctx, cred, homeRegion, domainID, idpID, enabled)
}
// DeleteIdentityProvider 级联删除 IdP:先删关联的免 MFA 规则,再从登录页
// 移除、停用、删除 IdP 本体。
func (s *OciConfigService) DeleteIdentityProvider(ctx context.Context, id uint, idpID string) error {
func (s *OciConfigService) DeleteIdentityProvider(ctx context.Context, id uint, domainID, idpID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
if err := s.deleteIdpExemptions(ctx, cred, homeRegion, idpID); err != nil {
if err := s.deleteIdpExemptions(ctx, cred, homeRegion, domainID, idpID); err != nil {
return err
}
return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, idpID)
return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, domainID, idpID)
}
// deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。
func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, idpID string) error {
rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region)
func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) error {
rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region, domainID)
if err != nil {
return fmt.Errorf("list sign-on rules before delete idp: %w", err)
}
@@ -81,7 +81,7 @@ func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Cre
if r.BuiltIn || !strings.Contains(r.ConditionValue, idpID) {
continue
}
if err := s.client.DeleteMfaExemptionRule(ctx, cred, region, r.ID); err != nil {
if err := s.client.DeleteMfaExemptionRule(ctx, cred, region, domainID, r.ID); err != nil {
return fmt.Errorf("delete exemption rule %s: %w", r.Name, err)
}
}
@@ -89,25 +89,25 @@ func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Cre
}
// DomainSamlMetadata 下载域的 SP SAML 元数据 XML(提供给 IdP 侧配置)。
func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint) ([]byte, error) {
func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, domainID string) ([]byte, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion)
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
}
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint) ([]oci.SignOnRuleInfo, error) {
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion)
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion, domainID)
}
// CreateMfaExemption 为指定 IdP 创建免 MFA 规则并置为最高优先级。
func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, idpID string) (oci.SignOnRuleInfo, error) {
func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, domainID, idpID string) (oci.SignOnRuleInfo, error) {
if strings.TrimSpace(idpID) == "" {
return oci.SignOnRuleInfo{}, fmt.Errorf("create mfa exemption: identityProviderId is required")
}
@@ -115,16 +115,16 @@ func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, idpI
if err != nil {
return oci.SignOnRuleInfo{}, err
}
name, err := s.exemptionRuleName(ctx, cred, homeRegion, idpID)
name, err := s.exemptionRuleName(ctx, cred, homeRegion, domainID, idpID)
if err != nil {
return oci.SignOnRuleInfo{}, err
}
return s.client.CreateMfaExemptionRule(ctx, cred, homeRegion, idpID, name)
return s.client.CreateMfaExemptionRule(ctx, cred, homeRegion, domainID, idpID, name)
}
// exemptionRuleName 校验 IdP 存在并用其名称生成规则名。
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, idpID string) (string, error) {
idps, err := s.client.ListIdentityProviders(ctx, cred, region)
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) (string, error) {
idps, err := s.client.ListIdentityProviders(ctx, cred, region, domainID)
if err != nil {
return "", err
}
@@ -137,10 +137,10 @@ func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Crede
}
// DeleteMfaExemption 删除免 MFA 规则并恢复其余规则优先级。
func (s *OciConfigService) DeleteMfaExemption(ctx context.Context, id uint, ruleID string) error {
func (s *OciConfigService) DeleteMfaExemption(ctx context.Context, id uint, domainID, ruleID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, ruleID)
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, domainID, ruleID)
}
+133 -40
View File
@@ -57,6 +57,9 @@ type LogEventService struct {
relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认
relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认
alertMu sync.Mutex // 保护告警规则冷却表
alertSentAt map[uint]time.Time // 规则 ID → 上次告警时刻(阈值型规则冷却)
}
// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。
@@ -80,37 +83,62 @@ type LogWebhookInfo struct {
// EnsureSecret 为配置生成(或幂等返回)回传 secret。
func (s *LogEventService) EnsureSecret(ctx context.Context, cfgID uint) (LogWebhookInfo, error) {
if err := s.requireConfig(ctx, cfgID); err != nil {
secret, err := generateWebhookSecret()
if err != nil {
return LogWebhookInfo{}, err
}
if info, ok, err := s.SecretInfo(ctx, cfgID); err != nil || ok {
return info, err
var info LogWebhookInfo
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
info, err = ensureSecretTx(tx, cfgID, secret)
return err
})
if err != nil {
return LogWebhookInfo{}, fmt.Errorf("ensure webhook secret: %w", err)
}
return info, nil
}
func generateWebhookSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return LogWebhookInfo{}, fmt.Errorf("generate webhook secret: %w", err)
return "", fmt.Errorf("generate webhook secret: %w", err)
}
return hex.EncodeToString(buf), nil
}
func ensureSecretTx(tx *gorm.DB, cfgID uint, secret string) (LogWebhookInfo, error) {
if err := lockOciConfig(tx, cfgID); err != nil {
return LogWebhookInfo{}, err
}
if info, ok, err := secretInfoTx(tx, cfgID); err != nil || ok {
return info, err
}
secret := hex.EncodeToString(buf)
st := model.Setting{Key: secretKey(cfgID), Value: secret, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
if err := tx.Create(&st).Error; err != nil {
return LogWebhookInfo{}, fmt.Errorf("save webhook secret: %w", err)
}
return webhookInfo(secret, st.UpdatedAt), nil
}
// requireConfig 校验配置存在,不存在透传 gorm.ErrRecordNotFound(api 层映射 404)。
func (s *LogEventService) requireConfig(ctx context.Context, cfgID uint) error {
var cfg model.OciConfig
if err := s.db.WithContext(ctx).Select("id").First(&cfg, cfgID).Error; err != nil {
return fmt.Errorf("find oci config %d: %w", cfgID, err)
}
return nil
}
// SecretInfo 查询配置是否已生成 secret;未生成时 ok 为 false。
func (s *LogEventService) SecretInfo(ctx context.Context, cfgID uint) (LogWebhookInfo, bool, error) {
var info LogWebhookInfo
var ok bool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
var err error
info, ok, err = secretInfoTx(tx, cfgID)
return err
})
return info, ok, err
}
func secretInfoTx(tx *gorm.DB, cfgID uint) (LogWebhookInfo, bool, error) {
var st model.Setting
err := s.db.WithContext(ctx).First(&st, "key = ?", secretKey(cfgID)).Error
err := tx.First(&st, "key = ?", secretKey(cfgID)).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return LogWebhookInfo{}, false, nil
@@ -154,19 +182,61 @@ func (s *LogEventService) ResolveSecret(ctx context.Context, secret string) (uin
return 0, false
}
for _, row := range rows {
if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) == 1 {
id, err := strconv.ParseUint(strings.TrimPrefix(row.Key, logWebhookSecretPrefix), 10, 64)
if err != nil {
return 0, false
}
return uint(id), true
if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) != 1 {
continue
}
id, ok := webhookConfigID(row.Key)
if !ok || !s.configExists(ctx, id) {
return 0, false
}
return id, true
}
return 0, false
}
// webhookConfigID 从 secret Setting 键解析租户配置 ID。
func webhookConfigID(key string) (uint, bool) {
id, err := strconv.ParseUint(strings.TrimPrefix(key, logWebhookSecretPrefix), 10, 64)
return uint(id), err == nil && id > 0
}
// configExists 确认 secret 对应租户仍存在;数据库异常按认证失败处理。
func (s *LogEventService) configExists(ctx context.Context, id uint) bool {
var cfg model.OciConfig
err := s.db.WithContext(ctx).Select("id").First(&cfg, id).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("resolve webhook config: %v", err)
}
return err == nil
}
// Ingest 落一条回传事件;MessageID 唯一索引冲突即静默忽略(at-least-once 幂等)。
func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
return createLogEvent(tx, cfgID, messageID, payload, truncated)
})
if err != nil {
return fmt.Errorf("ingest log event: %w", err)
}
return nil
}
// lockOciConfig 与租户删除共用行锁,避免并发清理后写入孤儿数据。
func lockOciConfig(tx *gorm.DB, cfgID uint) error {
var cfg model.OciConfig
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").First(&cfg, cfgID).Error
if err != nil {
return fmt.Errorf("lock oci config %d: %w", cfgID, err)
}
return nil
}
// createLogEvent 在已锁定租户的事务内幂等写入事件。
func createLogEvent(tx *gorm.DB, cfgID uint, messageID string, payload []byte, truncated bool) error {
event := model.LogEvent{
OciConfigID: cfgID,
MessageID: messageID,
@@ -174,13 +244,9 @@ func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID stri
Truncated: truncated,
ReceivedAt: time.Now(),
}
err := s.db.WithContext(ctx).
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true}).
Create(&event).Error
if err != nil {
return fmt.Errorf("ingest log event: %w", err)
}
return nil
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true,
}).Create(&event).Error
}
// LogEventQuery 是回传事件查询参数;CfgID 为 0 表示全部租户。
@@ -304,18 +370,44 @@ func (s *LogEventService) parseOnce(ctx context.Context) {
log.Printf("log event parse load: %v", err)
return
}
for i := range events {
e := &events[i]
parsed := parseLogEvent([]byte(e.Payload))
e.EventType, e.Source, e.SourceIP, e.EventTime =
parsed.EventType, parsed.Source, parsed.SourceIP, parsed.EventTime
e.Processed = true
if err := s.db.WithContext(ctx).Save(e).Error; err != nil {
log.Printf("log event parse save %d: %v", e.ID, err)
return
}
s.notifyCritical(ctx, e, parsed)
if len(events) == 0 {
return
}
rules := s.loadEnabledAlertRules(ctx)
for i := range events {
s.processLogEvent(ctx, &events[i], rules)
}
}
// processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent, rules []model.AlertRule) {
parsed := parseLogEvent([]byte(event.Payload))
if !s.updateParsedEvent(ctx, event, parsed) {
return
}
s.notifyCritical(ctx, event, parsed)
s.matchAlertRules(ctx, rules, event, parsed)
}
// updateParsedEvent 用条件 UPDATE 禁止 Save 在删除后隐式重建事件。
func (s *LogEventService) updateParsedEvent(ctx context.Context, event *model.LogEvent, parsed parsedEvent) bool {
updates := map[string]any{
"event_type": parsed.EventType, "source": parsed.Source,
"source_ip": parsed.SourceIP, "event_time": parsed.EventTime, "processed": true,
}
res := s.db.WithContext(ctx).Model(&model.LogEvent{}).
Where("id = ? AND processed = ?", event.ID, false).Updates(updates)
if res.Error != nil {
log.Printf("log event parse update %d: %v", event.ID, res.Error)
return false
}
if res.RowsAffected != 1 {
return false
}
event.EventType, event.Source, event.SourceIP, event.EventTime =
parsed.EventType, parsed.Source, parsed.SourceIP, parsed.EventTime
event.Processed = true
return true
}
// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段;
@@ -443,6 +535,7 @@ func (s *LogEventService) cleanupOnce(ctx context.Context) {
if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil {
log.Printf("log event cleanup: %v", err)
}
s.cleanupAlertHits(ctx)
}
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
+148 -4
View File
@@ -2,7 +2,9 @@ package service
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
@@ -28,7 +30,8 @@ func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil {
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{},
&model.AlertRule{}, &model.AlertRuleHit{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cfg := model.OciConfig{Alias: "测试租户"}
@@ -98,9 +101,103 @@ func TestEnsureAndResolveSecret(t *testing.T) {
}
func TestEnsureSecretRejectsUnknownConfig(t *testing.T) {
svc, _, _ := newLogEventEnv(t)
if _, err := svc.EnsureSecret(context.Background(), 9999); err == nil {
t.Fatal("ensure secret for unknown config succeeded, want error")
svc, db, _ := newLogEventEnv(t)
if _, err := svc.EnsureSecret(context.Background(), 9999); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("ensure unknown config error = %v, want record not found", err)
}
var count int64
db.Model(&model.Setting{}).Where("key = ?", secretKey(9999)).Count(&count)
if count != 0 {
t.Fatalf("unknown config secret rows = %d, want 0", count)
}
}
func TestEnsureSecretConcurrentDeleteNoOrphan(t *testing.T) {
svc, db := newConcurrentSecretEnv(t)
for i := 0; i < 8; i++ {
cfg := model.OciConfig{Alias: fmt.Sprintf("race-%d", i)}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
ensureErr, deleteErr := runEnsureDeleteRace(svc, db, cfg.ID)
if deleteErr != nil {
t.Fatalf("delete config %d: %v", cfg.ID, deleteErr)
}
if ensureErr != nil && !errors.Is(ensureErr, gorm.ErrRecordNotFound) {
t.Fatalf("ensure config %d: %v", cfg.ID, ensureErr)
}
assertNoSecretSetting(t, db, cfg.ID)
}
}
func newConcurrentSecretEnv(t *testing.T) (*LogEventService, *gorm.DB) {
t.Helper()
dsn := filepath.Join(t.TempDir(), "secret.db") + "?_txlock=immediate&_pragma=busy_timeout%3d5000"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewLogEventService(db), db
}
func runEnsureDeleteRace(svc *LogEventService, db *gorm.DB, cfgID uint) (error, error) {
start := make(chan struct{})
ensureDone, deleteDone := make(chan error, 1), make(chan error, 1)
go func() {
<-start
_, err := svc.EnsureSecret(context.Background(), cfgID)
ensureDone <- err
}()
go func() {
<-start
deleteDone <- deleteConfigAndSecret(db, cfgID)
}()
close(start)
return <-ensureDone, <-deleteDone
}
func deleteConfigAndSecret(db *gorm.DB, cfgID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
if err := tx.Delete(&model.Setting{}, "key = ?", secretKey(cfgID)).Error; err != nil {
return err
}
return tx.Delete(&model.OciConfig{}, cfgID).Error
})
}
func assertNoSecretSetting(t *testing.T, db *gorm.DB, cfgID uint) {
t.Helper()
var count int64
db.Model(&model.Setting{}).Where("key = ?", secretKey(cfgID)).Count(&count)
if count != 0 {
t.Fatalf("orphan secret rows for config %d = %d", cfgID, count)
}
}
func TestResolveSecretRejectsOrphanSetting(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
orphan := model.Setting{Key: secretKey(9999), Value: "orphan-secret"}
if err := db.Create(&orphan).Error; err != nil {
t.Fatalf("create orphan secret: %v", err)
}
if id, ok := svc.ResolveSecret(context.Background(), orphan.Value); ok || id != 0 {
t.Fatalf("resolve orphan secret = (%d,%v), want (0,false)", id, ok)
}
info, ok, err := svc.SecretInfo(context.Background(), 9999)
if !errors.Is(err, gorm.ErrRecordNotFound) || ok || info.Secret != "" {
t.Fatalf("orphan secret info = (%q,%v,%v), want empty,false,record not found", info.Secret, ok, err)
}
}
@@ -127,6 +224,19 @@ func TestIngestIdempotent(t *testing.T) {
}
}
func TestIngestRejectsUnknownConfig(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
err := svc.Ingest(context.Background(), 9999, "orphan", []byte(`{}`), false)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("ingest unknown config error = %v, want record not found", err)
}
var count int64
db.Model(&model.LogEvent{}).Where("message_id = ?", "orphan").Count(&count)
if count != 0 {
t.Fatalf("orphan event rows = %d, want 0", count)
}
}
func TestParseLogEvent(t *testing.T) {
ts := "2026-07-07T08:00:00Z"
tests := []struct {
@@ -209,6 +319,40 @@ func TestParseOnceMarksProcessed(t *testing.T) {
}
}
func TestUpdateParsedEventDoesNotRecreateDeleted(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
event := model.LogEvent{OciConfigID: cfgID, MessageID: "deleted", Payload: `{}`}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
if err := db.Delete(&model.LogEvent{}, event.ID).Error; err != nil {
t.Fatalf("delete event: %v", err)
}
if svc.updateParsedEvent(context.Background(), &event, parsedEvent{EventType: "x"}) {
t.Fatal("deleted event update reported success")
}
var count int64
db.Model(&model.LogEvent{}).Where("id = ?", event.ID).Count(&count)
if count != 0 {
t.Fatalf("deleted event was recreated, rows = %d", count)
}
}
func TestUpdateParsedEventOnlyOnce(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
event := model.LogEvent{OciConfigID: cfgID, MessageID: "once", Payload: `{}`}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
parsed := parsedEvent{EventType: "x"}
if !svc.updateParsedEvent(context.Background(), &event, parsed) {
t.Fatal("first conditional update should succeed")
}
if svc.updateParsedEvent(context.Background(), &event, parsed) {
t.Fatal("processed event should not update twice")
}
}
func TestLogEventCleanup(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
+96
View File
@@ -1,10 +1,18 @@
package service
import (
"strings"
"sync"
"time"
)
// maxGuardEntries 是 failures / lockedAt 各自的条目上限(S-03):
// 攻击者持续提交唯一用户名时先清过期、仍满驱逐最旧,内存有界。
const maxGuardEntries = 4096
// guardUserMax 是计入守卫 key 的用户名字节上限,超长截断防高基数撑爆。
const guardUserMax = 32
// loginGuard 按「IP|用户名」双维度做滑动窗口失败计数与锁定;
// 窗口与锁定时长同值、阈值由调用方按安全设置传入(探索文档主题三);
// 内存实现(单体面板),重启清零可接受。
@@ -21,6 +29,16 @@ func newLoginGuard() *loginGuard {
}
}
// guardKey 规范化守卫键:用户名去首尾空白并按字节截断,
// 高基数/超长输入不会产生无界的独立条目。
func guardKey(clientIP, username string) string {
name := strings.TrimSpace(username)
if len(name) > guardUserMax {
name = name[:guardUserMax]
}
return clientIP + "|" + name
}
// locked 判定 key 是否处于锁定期;过期锁惰性清除。
func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bool {
g.mu.Lock()
@@ -37,9 +55,13 @@ func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bo
}
// fail 记一次失败并裁剪窗口;达到阈值转入锁定并返回 true(仅触发那一次)。
// 新建条目前先保证容量(过期清理 + 最旧驱逐),防高基数 key 单向增长。
func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Duration) bool {
g.mu.Lock()
defer g.mu.Unlock()
if _, exists := g.failures[key]; !exists {
g.ensureCapacity(now, window)
}
kept := g.failures[key][:0]
for _, t := range g.failures[key] {
if now.Sub(t) < window {
@@ -49,6 +71,9 @@ func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Dura
kept = append(kept, now)
if len(kept) >= limit {
delete(g.failures, key)
// 转入锁定同样要保证容量:该写入发生在已有 failures 条目的
// 第 N 次失败,不经过新建条目路径的 ensureCapacity
g.ensureLockCapacity(now, window)
g.lockedAt[key] = now
return true
}
@@ -56,6 +81,77 @@ func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Dura
return false
}
// ensureLockCapacity 在写入 lockedAt 前保证容量:先清已过锁定期的锁,
// 仍满驱逐锁定最早的条目(其剩余锁定时间最短,提前解锁的影响最小)。
func (g *loginGuard) ensureLockCapacity(now time.Time, lockFor time.Duration) {
if len(g.lockedAt) < maxGuardEntries {
return
}
for k, at := range g.lockedAt {
if now.Sub(at) >= lockFor {
delete(g.lockedAt, k)
}
}
if len(g.lockedAt) >= maxGuardEntries {
g.evictOldestLock()
}
}
// evictOldestLock 驱逐锁定时间最早的 lockedAt 条目(调用方须持锁)。
func (g *loginGuard) evictOldestLock() {
var oldestKey string
var oldestAt time.Time
for k, at := range g.lockedAt {
if oldestKey == "" || at.Before(oldestAt) {
oldestKey, oldestAt = k, at
}
}
if oldestKey != "" {
delete(g.lockedAt, oldestKey)
}
}
// ensureCapacity 在新建 failures 条目前保证容量:先清出窗口外的失败记录
// 与已过锁定期的锁,仍满则驱逐最后失败时间最早的条目(调用方须持锁)。
func (g *loginGuard) ensureCapacity(now time.Time, window time.Duration) {
if len(g.failures) < maxGuardEntries && len(g.lockedAt) < maxGuardEntries {
return
}
for k, times := range g.failures {
if len(times) == 0 || now.Sub(times[len(times)-1]) >= window {
delete(g.failures, k)
}
}
for k, at := range g.lockedAt {
if now.Sub(at) >= window {
delete(g.lockedAt, k)
}
}
if len(g.failures) >= maxGuardEntries {
g.evictOldest()
}
}
// evictOldest 驱逐最后失败时间最早的 failures 条目(调用方须持锁);
// 仅在容量兜底时触发,O(n) 扫描可接受。
func (g *loginGuard) evictOldest() {
var oldestKey string
var oldestAt time.Time
for k, times := range g.failures {
if len(times) == 0 {
delete(g.failures, k)
continue
}
last := times[len(times)-1]
if oldestKey == "" || last.Before(oldestAt) {
oldestKey, oldestAt = k, last
}
}
if oldestKey != "" {
delete(g.failures, oldestKey)
}
}
// success 清空 key 的失败计数(成功登录重置窗口)。
func (g *loginGuard) success(key string) {
g.mu.Lock()
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"fmt"
"strings"
"testing"
"time"
)
// TestGuardKeyNormalization 用户名去空白并按字节截断,高基数超长输入收敛。
func TestGuardKeyNormalization(t *testing.T) {
tests := []struct {
name string
ip string
user string
want string
}{
{name: "常规", ip: "1.2.3.4", user: "admin", want: "1.2.3.4|admin"},
{name: "去空白", ip: "1.2.3.4", user: " admin ", want: "1.2.3.4|admin"},
{name: "超长截断", ip: "1.2.3.4", user: strings.Repeat("a", 100), want: "1.2.3.4|" + strings.Repeat("a", guardUserMax)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := guardKey(tt.ip, tt.user); got != tt.want {
t.Errorf("guardKey = %q, want %q", got, tt.want)
}
})
}
}
// TestGuardCapacityBounded 高基数唯一 key 持续失败,条目数不超过上限。
func TestGuardCapacityBounded(t *testing.T) {
g := newLoginGuard()
now := time.Now()
for i := 0; i < maxGuardEntries*2; i++ {
g.fail(fmt.Sprintf("1.2.3.4|user-%d", i), now, 100, time.Hour)
}
if n := len(g.failures); n > maxGuardEntries {
t.Errorf("failures 条目 = %d, want ≤ %d", n, maxGuardEntries)
}
}
// TestGuardCapacitySweepExpired 容量满时优先清理窗口外条目,而非驱逐活跃条目。
func TestGuardCapacitySweepExpired(t *testing.T) {
g := newLoginGuard()
base := time.Now()
window := time.Hour
// 填满:全部为「早已过窗」的旧失败
for i := 0; i < maxGuardEntries; i++ {
g.fail(fmt.Sprintf("old|%d", i), base.Add(-2*window), 100, window)
}
// 新失败触发清理:过期条目被清,新条目正常记录
g.fail("fresh|user", base, 100, window)
if _, ok := g.failures["fresh|user"]; !ok {
t.Error("新条目未记录")
}
if n := len(g.failures); n > maxGuardEntries {
t.Errorf("清理后条目 = %d, want ≤ %d", n, maxGuardEntries)
}
}
// TestGuardEvictOldestKeepsNewest 无过期可清时驱逐最旧,最新条目保留。
func TestGuardEvictOldestKeepsNewest(t *testing.T) {
g := newLoginGuard()
base := time.Now()
window := 24 * time.Hour
for i := 0; i < maxGuardEntries; i++ {
// 均在窗口内,时间递增;user-0 最旧
g.fail(fmt.Sprintf("ip|user-%d", i), base.Add(time.Duration(i)*time.Second), 100, window)
}
g.fail("ip|newest", base.Add(time.Duration(maxGuardEntries+1)*time.Second), 100, window)
if _, ok := g.failures["ip|newest"]; !ok {
t.Error("最新条目未记录")
}
if _, ok := g.failures["ip|user-0"]; ok {
t.Error("最旧条目未被驱逐")
}
}
// TestGuardLockCapacityBounded 高基数唯一 key 全部打满阈值转入锁定,
// 锁定期内(无可清理的过期锁)条目数仍不超过上限,最新锁保留、最早锁被驱逐。
func TestGuardLockCapacityBounded(t *testing.T) {
g := newLoginGuard()
base := time.Now()
window := 15 * time.Minute
total := maxGuardEntries + 100
for i := 0; i < total; i++ {
key := fmt.Sprintf("1.2.3.4|user-%d", i)
// 每个 key 打满 2 次(阈值)即转入锁定;时间递增,i 越小锁定越早
at := base.Add(time.Duration(i) * time.Millisecond)
g.fail(key, at, 2, window)
if !g.fail(key, at, 2, window) {
t.Fatalf("key %s 未按阈值转入锁定", key)
}
}
if n := len(g.lockedAt); n > maxGuardEntries {
t.Errorf("lockedAt 条目 = %d, want ≤ %d", n, maxGuardEntries)
}
if _, ok := g.lockedAt[fmt.Sprintf("1.2.3.4|user-%d", total-1)]; !ok {
t.Error("最新锁定条目未保留")
}
if _, ok := g.lockedAt["1.2.3.4|user-0"]; ok {
t.Error("最早锁定条目未被驱逐")
}
}
+134 -18
View File
@@ -24,12 +24,12 @@ const notifyTextLimit = 3800
// telegramAPIBase 是 Telegram Bot API 官方根地址。
const telegramAPIBase = "https://api.telegram.org"
// Notifier 通过 Telegram Bot API 推送通知;未启用或未配置时静默跳过。
// 云外 HTTP 调用不属于 internal/oci 的职责范围。
// Notifier Telegram 与全部启用的通知渠道(webhook/ntfy/bark/smtp)推送;
// 未启用或未配置的渠道静默跳过。云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // API 根地址测试时注入 httptest 服务
base string // Telegram API 根地址,测试时注入 httptest 服务
wg sync.WaitGroup
}
@@ -42,8 +42,28 @@ func NewNotifier(settings *SettingService) *Notifier {
}
}
// Send 读取配置并发送一条消息;未启用或未配置时直接返回 nil。
// Send 向全部启用渠道发送一条纯文本消息;无渠道可用时返回 nil。
func (n *Notifier) Send(ctx context.Context, text string) error {
return n.dispatch(ctx, notifyMsg{Title: "oci-portal 通知", Text: text})
}
// dispatch 把消息投递到 Telegram 与全部启用渠道;渠道间互不影响,错误合并返回供日志。
func (n *Notifier) dispatch(ctx context.Context, msg notifyMsg) error {
msg.Text = truncateText(msg.Text, notifyTextLimit)
var errs []error
if err := n.sendTelegram(ctx, msg); err != nil {
errs = append(errs, fmt.Errorf("telegram: %w", err))
}
for _, chType := range NotifyChannelTypes {
if err := n.sendChannel(ctx, chType, msg); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", chType, err))
}
}
return errors.Join(errs...)
}
// sendTelegram 走既有 Telegram 路径;未启用或未配置时静默跳过。
func (n *Notifier) sendTelegram(ctx context.Context, msg notifyMsg) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
@@ -51,7 +71,38 @@ func (n *Notifier) Send(ctx context.Context, text string) error {
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
return n.post(ctx, cfg, text)
return n.postHTML(ctx, cfg, mdToTelegramHTML(msg.Text))
}
// sendChannel 向单个新增渠道投递;未启用或关键字段缺失时静默跳过。
func (n *Notifier) sendChannel(ctx context.Context, chType string, msg notifyMsg) error {
switch chType {
case ChannelWebhook:
cfg, err := n.settings.webhookChannel(ctx)
if err != nil || !cfg.Enabled || cfg.URL == "" {
return err
}
return sendWebhook(ctx, n.client, cfg, msg)
case ChannelNtfy:
cfg, err := n.settings.ntfyChannel(ctx)
if err != nil || !cfg.Enabled || cfg.Topic == "" {
return err
}
return sendNtfy(ctx, n.client, cfg, msg)
case ChannelBark:
cfg, err := n.settings.barkChannel(ctx)
if err != nil || !cfg.Enabled || cfg.DeviceKey == "" {
return err
}
return sendBark(ctx, n.client, cfg, msg)
case ChannelSMTP:
cfg, err := n.settings.smtpChannel(ctx)
if err != nil || !cfg.Enabled || cfg.Host == "" {
return err
}
return sendSMTP(ctx, cfg, msg)
}
return nil
}
// SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。
@@ -62,13 +113,13 @@ func (n *Notifier) SendAsync(text string) {
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.Send(ctx, text); err != nil {
log.Printf("telegram notify: %v", err)
log.Printf("notify: %v", err)
}
}()
}
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
// 模板支持轻量 Markdown,Telegram HTML 语义投递。
// 模板支持轻量 Markdown,Telegram HTML 语义投递,其余渠道按纯文本投递
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
n.wg.Add(1)
go func() {
@@ -76,7 +127,7 @@ func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.sendTemplate(ctx, kind, "", vars); err != nil {
log.Printf("telegram notify %s: %v", kind, err)
log.Printf("notify %s: %v", kind, err)
}
}()
}
@@ -88,12 +139,12 @@ func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride strin
if !ok {
return fmt.Errorf("未知通知类型 %q", kind)
}
cfg, err := n.settings.TelegramConfig(ctx)
enabled, err := n.anyChannelEnabled(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
if !enabled {
return fmt.Errorf("没有已启用的通知渠道,请先在「通知方式」配置并启用")
}
vars := map[string]string{}
for k, v := range def.Sample {
@@ -102,15 +153,29 @@ func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride strin
return n.sendTemplate(ctx, kind, tplOverride, vars)
}
// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
cfg, err := n.settings.TelegramConfig(ctx)
// anyChannelEnabled 报告是否存在至少一个已启用且配置齐全的渠道(含 Telegram)
func (n *Notifier) anyChannelEnabled(ctx context.Context) (bool, error) {
tg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
return false, err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
if tg.Enabled && tg.BotToken != "" && tg.ChatID != "" {
return true, nil
}
views, err := n.settings.NotifyChannels(ctx)
if err != nil {
return false, err
}
for _, v := range views {
if v.Enabled {
return true, nil
}
}
return false, nil
}
// sendTemplate 渲染并分发:取生效模板 → 变量替换 → 投递到全部启用渠道。
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
tpl := tplOverride
if strings.TrimSpace(tpl) == "" {
custom, err := n.settings.NotifyTemplate(ctx, kind)
@@ -120,7 +185,58 @@ func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, v
tpl = notifyTplText(custom, kind)
}
text := renderNotifyTemplate(tpl, vars)
return n.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit)))
return n.dispatch(ctx, notifyMsg{Title: notifyTplLabel(kind), Text: text})
}
// SendChannelTest 向指定渠道同步发送测试消息;不看启用开关,便于保存后即时验证。
func (n *Notifier) SendChannelTest(ctx context.Context, chType string) error {
msg := notifyMsg{Title: "oci-portal 测试消息", Text: "✅ 通知渠道配置成功:" + chType}
switch chType {
case ChannelWebhook:
cfg, err := n.settings.webhookChannel(ctx)
if err != nil {
return err
}
if cfg.URL == "" {
return fmt.Errorf("请先保存 Webhook URL")
}
return sendWebhook(ctx, n.client, cfg, msg)
case ChannelNtfy:
cfg, err := n.settings.ntfyChannel(ctx)
if err != nil {
return err
}
if cfg.Topic == "" {
return fmt.Errorf("请先保存 ntfy topic")
}
return sendNtfy(ctx, n.client, cfg, msg)
}
return n.sendChannelTestRest(ctx, chType, msg)
}
// sendChannelTestRest 承接 SendChannelTest 的 bark/smtp 分支(拆分控制函数长度)。
func (n *Notifier) sendChannelTestRest(ctx context.Context, chType string, msg notifyMsg) error {
switch chType {
case ChannelBark:
cfg, err := n.settings.barkChannel(ctx)
if err != nil {
return err
}
if cfg.DeviceKey == "" {
return fmt.Errorf("请先保存 Bark device key")
}
return sendBark(ctx, n.client, cfg, msg)
case ChannelSMTP:
cfg, err := n.settings.smtpChannel(ctx)
if err != nil {
return err
}
if cfg.Host == "" || cfg.From == "" || cfg.To == "" {
return fmt.Errorf("请先保存 SMTP 主机、发件人与收件人")
}
return sendSMTP(ctx, cfg, msg)
}
return ErrUnknownNotifyChannel
}
// Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。
+218
View File
@@ -0,0 +1,218 @@
package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"mime"
"net"
"net/http"
"net/smtp"
"net/url"
"strings"
)
// 通知渠道类型;telegram 走既有独立路径,不纳入此枚举。
const (
ChannelWebhook = "webhook"
ChannelNtfy = "ntfy"
ChannelBark = "bark"
ChannelSMTP = "smtp"
)
// NotifyChannelTypes 是新增渠道的稳定顺序(视图输出与遍历发送共用)。
var NotifyChannelTypes = []string{ChannelWebhook, ChannelNtfy, ChannelBark, ChannelSMTP}
// notifyMsg 是渲染完成的渠道无关载荷:Title 取通知类型显示名,Text 为模板渲染文本。
type notifyMsg struct {
Title string
Text string
}
// defaultWebhookBody 是通用 Webhook 的缺省 body 模板(兼容 Slack incoming webhook)。
const defaultWebhookBody = `{"text": "{{title}}\n{{text}}"}`
// 渠道服务端缺省地址。
const (
defaultNtfyServer = "https://ntfy.sh"
defaultBarkServer = "https://api.day.app"
)
// WebhookChannel 是通用 Webhook 渠道配置;BodyTemplate 空串按缺省模板发送。
type WebhookChannel struct {
Enabled bool
URL string
BodyTemplate string
}
// NtfyChannel 是 ntfy 渠道配置;Server 空串用官方 ntfy.sh,Token 可选。
type NtfyChannel struct {
Enabled bool
Server string
Topic string
Token string
}
// BarkChannel 是 Bark(iOS)渠道配置;Server 空串用官方 api.day.app。
type BarkChannel struct {
Enabled bool
Server string
DeviceKey string
}
// SMTPChannel 是邮件渠道配置;465 端口走隐式 TLS,其余端口 STARTTLS,Username 空则免认证。
type SMTPChannel struct {
Enabled bool
Host string
Port int
Username string
Password string
From string
To string
}
// validNotifyURL 校验渠道出站地址:仅接受 http/https 且 host 非空。
func validNotifyURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("地址须为 http(s):// 开头的合法 URL")
}
return nil
}
// renderWebhookBody 渲染 body 模板:占位值先做 JSON 字符串转义(去掉包裹引号)再替换,
// 保证换行/引号嵌入 JSON 字符串后仍合法。
func renderWebhookBody(tpl string, msg notifyMsg) string {
if strings.TrimSpace(tpl) == "" {
tpl = defaultWebhookBody
}
r := strings.NewReplacer("{{title}}", jsonEscape(msg.Title), "{{text}}", jsonEscape(msg.Text))
return r.Replace(tpl)
}
// jsonEscape 返回 s 作为 JSON 字符串字面量的内容(不含首尾引号)。
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}
// sendWebhook 向自定义 endpoint POST 渲染后的 JSON body。
func sendWebhook(ctx context.Context, client *http.Client, cfg WebhookChannel, msg notifyMsg) error {
body := renderWebhookBody(cfg.BodyTemplate, msg)
return postJSON(ctx, client, cfg.URL, "", []byte(body))
}
// sendNtfy 以 JSON publish 模式向 ntfy 服务端根路径发布(UTF-8 标题无兼容问题)。
func sendNtfy(ctx context.Context, client *http.Client, cfg NtfyChannel, msg notifyMsg) error {
server := cfg.Server
if server == "" {
server = defaultNtfyServer
}
body, err := json.Marshal(map[string]string{"topic": cfg.Topic, "title": msg.Title, "message": msg.Text})
if err != nil {
return fmt.Errorf("ntfy send: %w", err)
}
return postJSON(ctx, client, strings.TrimRight(server, "/"), cfg.Token, body)
}
// sendBark 调用 Bark v2 push 接口。
func sendBark(ctx context.Context, client *http.Client, cfg BarkChannel, msg notifyMsg) error {
server := cfg.Server
if server == "" {
server = defaultBarkServer
}
body, err := json.Marshal(map[string]string{"title": msg.Title, "body": msg.Text, "device_key": cfg.DeviceKey})
if err != nil {
return fmt.Errorf("bark send: %w", err)
}
return postJSON(ctx, client, strings.TrimRight(server, "/")+"/push", "", body)
}
// postJSON 发送 JSON POST;token 非空时附 Bearer 认证,非 2xx 一律视为失败。
func postJSON(ctx context.Context, client *http.Client, u, token string, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return sanitizeURLError(err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return sanitizeURLError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d", resp.StatusCode)
}
return nil
}
// sendSMTP 组装邮件报文并投递;主题按 RFC2047 编码,正文 UTF-8 纯文本。
func sendSMTP(ctx context.Context, cfg SMTPChannel, msg notifyMsg) error {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", cfg.From)
fmt.Fprintf(&b, "To: %s\r\n", cfg.To)
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", msg.Title))
b.WriteString("MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
b.WriteString(strings.ReplaceAll(msg.Text, "\n", "\r\n"))
return smtpDeliver(ctx, cfg, []byte(b.String()))
}
// smtpDeliver 建立 SMTP 会话:465 端口隐式 TLS,其余端口连通后尝试 STARTTLS。
func smtpDeliver(ctx context.Context, cfg SMTPChannel, mail []byte) error {
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
d := &net.Dialer{}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
if cfg.Port == 465 {
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp handshake: %w", err)
}
defer c.Close()
return smtpWrite(c, cfg, mail)
}
// smtpWrite 在既有会话上完成 STARTTLS/认证/收发件与报文写入。
func smtpWrite(c *smtp.Client, cfg SMTPChannel, mail []byte) error {
if cfg.Port != 465 {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
}
if cfg.Username != "" {
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(cfg.From); err != nil {
return fmt.Errorf("smtp from: %w", err)
}
if err := c.Rcpt(cfg.To); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(mail); err != nil {
w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close: %w", err)
}
return c.Quit()
}
+339
View File
@@ -0,0 +1,339 @@
package service
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestRenderWebhookBody(t *testing.T) {
msg := notifyMsg{Title: "任务失败", Text: "line1\nline\"2\""}
tests := []struct {
name string
tpl string
want string
}{
{
name: "空模板用缺省 Slack 形状",
tpl: "",
want: `{"text": "任务失败\nline1\nline\"2\""}`,
},
{
name: "自定义模板占位替换并转义",
tpl: `{"msgtype":"text","text":{"content":"{{text}}"}}`,
want: `{"msgtype":"text","text":{"content":"line1\nline\"2\""}}`,
},
{
name: "无占位原样输出",
tpl: `{"fixed":1}`,
want: `{"fixed":1}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := renderWebhookBody(tt.tpl, msg)
if got != tt.want {
t.Errorf("renderWebhookBody = %s, want %s", got, tt.want)
}
if !json.Valid([]byte(got)) {
t.Errorf("渲染结果不是合法 JSON: %s", got)
}
})
}
}
// jsonCapture 收集一次 HTTP 请求的路径、认证头与 JSON body。
type jsonCapture struct {
mu sync.Mutex
path string
auth string
fields map[string]string
}
// newCaptureServer 起假渠道服务端,固定返回 status。
func newCaptureServer(t *testing.T, status int) (*httptest.Server, *jsonCapture) {
t.Helper()
rec := &jsonCapture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.mu.Lock()
defer rec.mu.Unlock()
rec.path = r.URL.Path
rec.auth = r.Header.Get("Authorization")
rec.fields = map[string]string{}
_ = json.NewDecoder(r.Body).Decode(&rec.fields)
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, rec
}
func TestSendNtfy(t *testing.T) {
srv, rec := newCaptureServer(t, http.StatusOK)
cfg := NtfyChannel{Server: srv.URL, Topic: "oci", Token: "tk-1"}
msg := notifyMsg{Title: "标题", Text: "正文"}
if err := sendNtfy(context.Background(), srv.Client(), cfg, msg); err != nil {
t.Fatalf("sendNtfy: %v", err)
}
if rec.auth != "Bearer tk-1" {
t.Errorf("Authorization = %q, want Bearer tk-1", rec.auth)
}
want := map[string]string{"topic": "oci", "title": "标题", "message": "正文"}
for k, v := range want {
if rec.fields[k] != v {
t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
}
}
}
func TestSendBark(t *testing.T) {
srv, rec := newCaptureServer(t, http.StatusOK)
cfg := BarkChannel{Server: srv.URL, DeviceKey: "dk-1"}
if err := sendBark(context.Background(), srv.Client(), cfg, notifyMsg{Title: "标题", Text: "正文"}); err != nil {
t.Fatalf("sendBark: %v", err)
}
if rec.path != "/push" {
t.Errorf("path = %q, want /push", rec.path)
}
want := map[string]string{"title": "标题", "body": "正文", "device_key": "dk-1"}
for k, v := range want {
if rec.fields[k] != v {
t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
}
}
}
func TestPostJSONNon2xx(t *testing.T) {
srv, _ := newCaptureServer(t, http.StatusBadGateway)
err := postJSON(context.Background(), srv.Client(), srv.URL, "", []byte(`{}`))
if err == nil || !strings.Contains(err.Error(), "502") {
t.Fatalf("postJSON err = %v, want contains 502", err)
}
}
func TestValidNotifyURL(t *testing.T) {
tests := []struct {
raw string
wantErr bool
}{
{"https://example.com/hook", false},
{"http://10.0.0.1:8080/x", false},
{"ftp://example.com", true},
{"not-a-url", true},
{"", true},
}
for _, tt := range tests {
if err := validNotifyURL(tt.raw); (err != nil) != tt.wantErr {
t.Errorf("validNotifyURL(%q) err = %v, wantErr %v", tt.raw, err, tt.wantErr)
}
}
}
// fakeSMTP 起一个最小 SMTP 服务端,按标准会话应答并收集 DATA 报文。
func fakeSMTP(t *testing.T) (addr string, got *strings.Builder) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
got = &strings.Builder{}
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprint(conn, "220 fake ESMTP\r\n")
r := bufio.NewReader(conn)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
fmt.Fprint(conn, "250-fake\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(cmd, "AUTH"):
fmt.Fprint(conn, "235 ok\r\n")
case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"):
fmt.Fprint(conn, "250 ok\r\n")
case cmd == "DATA":
fmt.Fprint(conn, "354 go\r\n")
readSMTPData(r, got)
fmt.Fprint(conn, "250 ok\r\n")
case cmd == "QUIT":
fmt.Fprint(conn, "221 bye\r\n")
return
default:
fmt.Fprint(conn, "250 ok\r\n")
}
}
}()
return ln.Addr().String(), got
}
// readSMTPData 读取 DATA 段直到单独一行 "."。
func readSMTPData(r *bufio.Reader, got *strings.Builder) {
for {
line, err := r.ReadString('\n')
if err != nil || strings.TrimRight(line, "\r\n") == "." {
return
}
got.WriteString(line)
}
}
func TestSendSMTP(t *testing.T) {
addr, got := fakeSMTP(t)
host, portStr, _ := net.SplitHostPort(addr)
var port int
fmt.Sscanf(portStr, "%d", &port)
cfg := SMTPChannel{
Host: host, Port: port,
Username: "u", Password: "p",
From: "a@x.com", To: "b@y.com",
}
err := sendSMTP(context.Background(), cfg, notifyMsg{Title: "任务失败", Text: "第一行\n第二行"})
if err != nil {
t.Fatalf("sendSMTP: %v", err)
}
mail := got.String()
for _, want := range []string{"From: a@x.com", "To: b@y.com", "Subject: =?utf-8?q?", "第一行\r\n第二行"} {
if !strings.Contains(mail, want) {
t.Errorf("mail 缺少 %q:\n%s", want, mail)
}
}
}
// TestDispatchFanout 验证多渠道并发投递:一个渠道失败不影响其他渠道送达。
func TestDispatchFanout(t *testing.T) {
okSrv, okRec := newCaptureServer(t, http.StatusOK)
badSrv, badRec := newCaptureServer(t, http.StatusInternalServerError)
settings, _ := newSettingEnv(t)
ctx := context.Background()
mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: true, URL: badSrv.URL})
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Enabled: true, Server: okSrv.URL, Topic: "oci"})
n := NewNotifier(settings)
err := n.dispatch(ctx, notifyMsg{Title: "T", Text: "body"})
if err == nil || !strings.Contains(err.Error(), "webhook") {
t.Fatalf("dispatch err = %v, want webhook 失败", err)
}
if badRec.fields == nil {
t.Error("webhook 渠道未收到请求")
}
if okRec.fields["message"] != "body" {
t.Errorf("ntfy 渠道未正常送达: %v", okRec.fields)
}
}
// TestDispatchSkipsDisabled 验证未启用/未配置的渠道静默跳过且不报错。
func TestDispatchSkipsDisabled(t *testing.T) {
srv, rec := newCaptureServer(t, http.StatusOK)
settings, _ := newSettingEnv(t)
mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: false, URL: srv.URL})
n := NewNotifier(settings)
if err := n.dispatch(context.Background(), notifyMsg{Title: "T", Text: "x"}); err != nil {
t.Fatalf("dispatch: %v", err)
}
if rec.fields != nil {
t.Error("未启用渠道不应收到请求")
}
}
// mustUpdateChannel 保存渠道配置,失败即终止测试。
func mustUpdateChannel(t *testing.T, s *SettingService, chType string, in UpdateNotifyChannelInput) {
t.Helper()
if err := s.UpdateNotifyChannel(context.Background(), chType, in); err != nil {
t.Fatalf("update channel %s: %v", chType, err)
}
}
func TestUpdateNotifyChannelValidation(t *testing.T) {
secret := "sec-123456"
tests := []struct {
name string
chType string
in UpdateNotifyChannelInput
wantErr string
}{
{name: "未知类型", chType: "pigeon", wantErr: "未知通知渠道"},
{name: "启用 webhook 缺 URL", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "URL"},
{name: "webhook 非法协议", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true, URL: "ftp://x"}, wantErr: "http(s)"},
{name: "启用 ntfy 缺 topic", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "topic"},
{name: "启用 smtp 缺主机", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Port: 587}, wantErr: "SMTP"},
{name: "smtp 端口越界", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Host: "h", Port: 70000, From: "a@x", To: "b@y"}, wantErr: "端口"},
{name: "未启用可存草稿", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: false}},
{name: "配置齐全可启用", chType: ChannelBark, in: UpdateNotifyChannelInput{Enabled: true, Secret: &secret}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings, _ := newSettingEnv(t)
err := settings.UpdateNotifyChannel(context.Background(), tt.chType, tt.in)
if tt.wantErr == "" && err != nil {
t.Fatalf("UpdateNotifyChannel: %v", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
// TestNotifyChannelSecretLifecycle 验证密文字段的存取语义:
// 保存后视图只回 set/tail,nil 沿用旧值,空串清除。
func TestNotifyChannelSecretLifecycle(t *testing.T) {
settings, _ := newSettingEnv(t)
ctx := context.Background()
secret := "ntfy-token-9876"
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t", Secret: &secret})
view := mustChannelView(t, settings, ChannelNtfy)
if !view.SecretSet || view.SecretTail != "9876" {
t.Fatalf("view = %+v, want SecretSet 且尾号 9876", view)
}
cfg, err := settings.ntfyChannel(ctx)
if err != nil || cfg.Token != secret {
t.Fatalf("ntfyChannel token = %q err = %v, want 原文", cfg.Token, err)
}
// nil 沿用
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2"})
if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != secret || cfg.Topic != "t2" {
t.Fatalf("nil Secret 应沿用旧 token,got %+v", cfg)
}
// 空串清除
empty := ""
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2", Secret: &empty})
if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != "" {
t.Fatalf("空串 Secret 应清除,got %q", cfg.Token)
}
if view := mustChannelView(t, settings, ChannelNtfy); view.SecretSet {
t.Fatal("清除后 SecretSet 应为 false")
}
}
// mustChannelView 取单渠道脱敏视图。
func mustChannelView(t *testing.T, s *SettingService, chType string) NotifyChannelView {
t.Helper()
views, err := s.NotifyChannels(context.Background())
if err != nil {
t.Fatalf("NotifyChannels: %v", err)
}
for _, v := range views {
if v.Type == chType {
return v
}
}
t.Fatalf("view %s not found", chType)
return NotifyChannelView{}
}
+23 -3
View File
@@ -21,6 +21,7 @@ var notifyTplOrder = []string{
"task_fail", "task_recover", "snatch_success", "tenant_dead", "task_stop",
"login_lock", "model_deprecated",
"log_event_instance", "log_event_identity", "log_event_policy", "log_event_region", "log_event_login",
"audit_alert",
}
var notifyTplDefs = map[string]notifyTplDef{
@@ -38,9 +39,13 @@ var notifyTplDefs = map[string]notifyTplDef{
},
"snatch_success": {
Label: "抢机成功",
Default: "🎉 抢机成功:{{task_name}}\n{{message}}",
Vars: []string{"task_name", "message"},
Sample: map[string]string{"task_name": "示例抢机任务", "message": "已创建 1/1 台实例"},
Default: "🎉 抢机成功:{{task_name}}\n租户:{{tenant}}\n区域:{{region}}\n类型:{{shape}}\n配置:{{spec}}\n镜像:{{image}}\nIP:{{ip}}\n密码:{{root_password}}",
Vars: []string{"task_name", "tenant", "region", "shape", "spec", "image", "ip", "root_password", "message"},
Sample: map[string]string{
"task_name": "示例抢机任务", "tenant": "免费01", "region": "Japan Central (Osaka)",
"shape": "VM.Standard.A1.Flex", "spec": "4C/24G", "image": "Canonical-Ubuntu-24.04-aarch64-2025.05.20-0",
"ip": "129.150.32.10", "root_password": "Xy3#kP9m-2Qw@7Zn", "message": "created 1: ocid1.instance…",
},
},
"tenant_dead": {
Label: "租户失联",
@@ -96,6 +101,13 @@ var notifyTplDefs = map[string]notifyTplDef{
Vars: []string{"tenant", "event", "resource"},
Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "demo@example.com"},
},
"audit_alert": {
Label: "审计告警",
Default: "🔔 审计告警:{{rule}}\n租户 {{tenant}} · {{event}} {{resource}}\n来源 {{ip}} · 窗口内 {{count}} 次",
Vars: []string{"rule", "tenant", "event", "resource", "ip", "count"},
Sample: map[string]string{"rule": "非白名单终止实例", "tenant": "免费01",
"event": "TerminateInstance", "resource": "web-server-1", "ip": "203.0.113.8", "count": "1"},
},
}
var notifyVarPattern = regexp.MustCompile(`\{\{(\w+)\}\}`)
@@ -161,3 +173,11 @@ func notifyTplText(custom, kind string) string {
}
return notifyTplDefs[kind].Default
}
// notifyTplLabel 返回 kind 的显示名,供渠道消息标题(ntfy/bark/邮件主题)使用。
func notifyTplLabel(kind string) string {
if def, ok := notifyTplDefs[kind]; ok {
return def.Label
}
return "oci-portal 通知"
}
+9 -3
View File
@@ -197,7 +197,12 @@ func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code
return "", "", p.mode, err
}
if p.mode == "bind" {
return "", ident.Display, p.mode, o.bind(ctx, p.username, provider, ident)
if err := o.bind(ctx, p.username, provider, ident); err != nil {
return "", ident.Display, p.mode, err
}
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
token, _, err := o.auth.RevokeSessions(ctx, p.username)
return token, ident.Display, p.mode, err
}
token, display, err = o.loginByIdentity(ctx, provider, ident)
return token, display, p.mode, err
@@ -305,7 +310,7 @@ func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ide
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
return "", "", fmt.Errorf("find bound user: %w", err)
}
token, _, err := o.auth.signToken(user.Username)
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
return token, ident.Display, err
}
@@ -345,5 +350,6 @@ func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) err
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
return o.auth.bumpTokenVersion(ctx, username)
}
+55 -14
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -15,10 +16,12 @@ import (
// OciConfigService 管理 API Key 配置的导入、测活与快照同步。
type OciConfigService struct {
db *gorm.DB
cipher *crypto.Cipher
client oci.Client
// auditRaw 暂存审计原始事件(eventId → raw,TTL 10 分钟),
db *gorm.DB
cipher *crypto.Cipher
client oci.Client
cleanupTasks *TaskService
cleanupEvents *LogEventService
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache
}
@@ -28,6 +31,12 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
}
// SetTenantCleanupDeps 注入租户删除提交后的任务与告警内存状态同步依赖。
func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService, events *LogEventService) {
s.cleanupTasks = tasks
s.cleanupEvents = events
}
// ImportInput 是导入一份 API Key 的输入:
// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。
type ImportInput struct {
@@ -263,17 +272,18 @@ func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig,
return &cfg, nil
}
// Delete 删除配置及其区域 / 区间缓存
// Delete 在单一事务中删除配置及全部租户级本地关联数据
func (s *OciConfigService) Delete(ctx context.Context, id uint) error {
res := s.db.WithContext(ctx).Delete(&model.OciConfig{}, id)
if res.Error != nil {
return fmt.Errorf("delete oci config %d: %w", id, res.Error)
unlock := func() {}
if s.cleanupTasks != nil {
unlock = s.cleanupTasks.lockTenantCleanup()
}
if res.RowsAffected == 0 {
return fmt.Errorf("delete oci config %d: %w", id, gorm.ErrRecordNotFound)
defer unlock()
result, err := s.deleteTenant(ctx, id)
if err != nil {
return err
}
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.RegionCache{})
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.CompartmentCache{})
s.afterTenantDelete(ctx, result)
return nil
}
@@ -294,12 +304,43 @@ func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cr
s.applyProfile(ctx, cfg, cred)
s.syncScopeCaches(ctx, cfg, cred)
}
if err := s.db.WithContext(ctx).Save(cfg).Error; err != nil {
return fmt.Errorf("save oci config snapshot: %w", err)
s.applySuspension(ctx, cfg, cred)
return s.saveSnapshot(ctx, cfg)
}
// saveSnapshot 只更新当前版本,避免在途测活把已删租户重新插入。
func (s *OciConfigService) saveSnapshot(ctx context.Context, cfg *model.OciConfig) error {
res := s.db.WithContext(ctx).Model(&model.OciConfig{}).
Where("id = ? AND updated_at = ?", cfg.ID, cfg.UpdatedAt).
Select("*").Omit("id", "created_at").Updates(cfg)
if res.Error != nil {
return fmt.Errorf("save oci config snapshot: %w", res.Error)
}
if res.RowsAffected != 1 {
return fmt.Errorf("save oci config snapshot: %w", gorm.ErrRecordNotFound)
}
return nil
}
// applySuspension 查询账户能力接口:云端标记暂停的租户覆盖测活结论为 suspended。
// 该接口对暂停/已终止租户仍可访问(常规 API 此时多被拒,会误判失联);查询失败不改变结论。
func (s *OciConfigService) applySuspension(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
caps, err := s.client.GetAccountCapabilities(ctx, cred, homeRegionOf(cfg))
if err != nil || !caps.Suspended {
return
}
cfg.AliveStatus = model.AliveStatusSuspended
cfg.LastError = strings.TrimSpace("账户已被云端暂停 " + capsStatusNote(caps))
}
// capsStatusNote 生成暂停详情备注(账户状态,如 terminated)。
func capsStatusNote(caps oci.AccountCapabilities) string {
if caps.AccountStatus == "" {
return ""
}
return "(account status: " + caps.AccountStatus + ")"
}
// applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。
func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
profile, err := s.client.FetchAccountProfile(ctx, cred)
+62
View File
@@ -25,6 +25,8 @@ type fakeClient struct {
tenancyErr error
profile oci.AccountProfile
profileErr error
caps oci.AccountCapabilities
capsErr error
regionSubs []oci.RegionSubscription
regionSubsErr error
@@ -43,6 +45,10 @@ type fakeClient struct {
instancesErr error
costItems []oci.CostItem
costErr error
instance oci.Instance
instanceErr error
image oci.Image
imageErr error
subscribedHomeRegion string
subscribedKey string
@@ -55,6 +61,10 @@ func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci
return f.tenancy, f.tenancyErr
}
func (f *fakeClient) GetAccountCapabilities(ctx context.Context, cred oci.Credentials, region string) (oci.AccountCapabilities, error) {
return f.caps, f.capsErr
}
func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) {
return f.profile, f.profileErr
}
@@ -107,6 +117,14 @@ func (f *fakeClient) ListInstances(ctx context.Context, cred oci.Credentials, re
return f.instances, f.instancesErr
}
func (f *fakeClient) GetInstance(ctx context.Context, cred oci.Credentials, region, instanceID string) (oci.Instance, error) {
return f.instance, f.instanceErr
}
func (f *fakeClient) GetImage(ctx context.Context, cred oci.Credentials, region, imageID string) (oci.Image, error) {
return f.image, f.imageErr
}
func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) {
return f.costItems, f.costErr
}
@@ -119,6 +137,13 @@ func newTestService(t *testing.T, client oci.Client) *OciConfigService {
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
// :memory: 库每条连接各自独立,后台 goroutine(模型验证等)触发第二条连接会看到空库;
// 收敛到单连接,与 tenantdelete 测试环境一致
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("database handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(
&model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{},
); err != nil {
@@ -312,6 +337,7 @@ func TestVerifyMissingConfig(t *testing.T) {
func TestDelete(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
migrateTenantDeleteModels(t, svc.db)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
@@ -458,3 +484,39 @@ func TestListReturnsSummaries(t *testing.T) {
t.Errorf("items[1] alias/group = %q/%q, want 生产号/empty", items[1].Alias, items[1].Group)
}
}
// TestVerifySuspendedOverridesStatus 云端暂停标记应覆盖测活结论为 suspended;
// 能力接口失败或未标记时不改变原结论。
func TestVerifySuspendedOverridesStatus(t *testing.T) {
client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 标记暂停:即使 ValidateKey 成功也应判 suspended
client.caps = oci.AccountCapabilities{Suspended: true, AccountStatus: "terminated"}
got, _, err := svc.Verify(ctx, cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.AliveStatus != model.AliveStatusSuspended {
t.Fatalf("AliveStatus = %q, want suspended", got.AliveStatus)
}
if got.LastError == "" {
t.Error("暂停时 LastError 应携带账户状态备注")
}
// 解除暂停:恢复 alive
client.caps = oci.AccountCapabilities{}
got, _, err = svc.Verify(ctx, cfg.ID)
if err != nil || got.AliveStatus != model.AliveStatusAlive {
t.Fatalf("解除暂停后 = %q, %v; want alive", got.AliveStatus, err)
}
// 能力接口失败:不影响测活结论
client.capsErr = errors.New("boom")
got, _, err = svc.Verify(ctx, cfg.ID)
if err != nil || got.AliveStatus != model.AliveStatusAlive {
t.Fatalf("能力接口失败后 = %q, %v; want alive", got.AliveStatus, err)
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (s *OciConfigService) overviewTenants(ctx context.Context, out *Overview) e
switch cfg.AliveStatus {
case model.AliveStatusAlive:
out.Tenants.Alive++
case model.AliveStatusDead:
case model.AliveStatusDead, model.AliveStatusSuspended:
out.Tenants.Dead++
}
}
+6
View File
@@ -39,6 +39,9 @@ func (s *OciConfigService) saveRegionCache(ctx context.Context, cfgID uint, subs
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.RegionCache{}).Error; err != nil {
return err
}
@@ -58,6 +61,9 @@ func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint,
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.CompartmentCache{}).Error; err != nil {
return err
}
+309
View File
@@ -0,0 +1,309 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
)
// 通知渠道配置键,前缀 notify_ch_<type>_;标注「密文」的键 AES-GCM 加密落库。
const (
settingChWebhookEnabled = "notify_ch_webhook_enabled" // "1"/"0"
settingChWebhookURL = "notify_ch_webhook_url"
settingChWebhookBodyTpl = "notify_ch_webhook_body_tpl"
settingChNtfyEnabled = "notify_ch_ntfy_enabled"
settingChNtfyServer = "notify_ch_ntfy_server"
settingChNtfyTopic = "notify_ch_ntfy_topic"
settingChNtfyToken = "notify_ch_ntfy_token" // 密文
settingChBarkEnabled = "notify_ch_bark_enabled"
settingChBarkServer = "notify_ch_bark_server"
settingChBarkDeviceKey = "notify_ch_bark_device_key" // 密文
settingChSMTPEnabled = "notify_ch_smtp_enabled"
settingChSMTPHost = "notify_ch_smtp_host"
settingChSMTPPort = "notify_ch_smtp_port"
settingChSMTPUsername = "notify_ch_smtp_username"
settingChSMTPPassword = "notify_ch_smtp_password" // 密文
settingChSMTPFrom = "notify_ch_smtp_from"
settingChSMTPTo = "notify_ch_smtp_to"
)
// ErrUnknownNotifyChannel 标记未知渠道类型,api 层映射 400。
var ErrUnknownNotifyChannel = fmt.Errorf("未知通知渠道类型")
// NotifyChannelView 是单渠道的脱敏视图:密文字段只回是否已配置与尾 4 位。
type NotifyChannelView struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
// webhook
URL string `json:"url,omitempty"`
BodyTemplate string `json:"bodyTemplate,omitempty"`
// ntfy / bark 共用 Server
Server string `json:"server,omitempty"`
Topic string `json:"topic,omitempty"`
// smtp
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
// 密文字段状态(ntfy token / bark device key / smtp password)
SecretSet bool `json:"secretSet"`
SecretTail string `json:"secretTail,omitempty"`
}
// UpdateNotifyChannelInput 是单渠道更新入参;Secret 为 nil 沿用已存密文,空串清除。
type UpdateNotifyChannelInput struct {
Enabled bool
URL string
BodyTemplate string
Server string
Topic string
Host string
Port int
Username string
From string
To string
Secret *string
}
// notifyChannelKeys 返回渠道的(启用键, 明文键列表, 密文键);未知类型返回启用键空串。
func notifyChannelKeys(chType string) (enabled string, plain []string, secret string) {
switch chType {
case ChannelWebhook:
return settingChWebhookEnabled, []string{settingChWebhookURL, settingChWebhookBodyTpl}, ""
case ChannelNtfy:
return settingChNtfyEnabled, []string{settingChNtfyServer, settingChNtfyTopic}, settingChNtfyToken
case ChannelBark:
return settingChBarkEnabled, []string{settingChBarkServer}, settingChBarkDeviceKey
case ChannelSMTP:
return settingChSMTPEnabled, []string{settingChSMTPHost, settingChSMTPPort,
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo}, settingChSMTPPassword
}
return "", nil, ""
}
// NotifyChannels 返回全部新增渠道的脱敏视图(不含 telegram,其沿用独立接口)。
func (s *SettingService) NotifyChannels(ctx context.Context) ([]NotifyChannelView, error) {
out := make([]NotifyChannelView, 0, len(NotifyChannelTypes))
for _, t := range NotifyChannelTypes {
v, err := s.notifyChannelView(ctx, t)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// notifyChannelView 组装单渠道脱敏视图。
func (s *SettingService) notifyChannelView(ctx context.Context, chType string) (NotifyChannelView, error) {
enabledKey, plain, secretKey := notifyChannelKeys(chType)
vals, err := s.getMany(ctx, append(plain, enabledKey)...)
if err != nil {
return NotifyChannelView{}, err
}
view := NotifyChannelView{Type: chType, Enabled: vals[enabledKey] == "1"}
fillChannelView(&view, chType, vals)
if secretKey == "" {
return view, nil
}
secret, err := s.decryptSetting(ctx, secretKey)
if err != nil {
return NotifyChannelView{}, err
}
view.SecretSet = secret != ""
view.SecretTail = tokenTail(secret)
return view, nil
}
// fillChannelView 把明文键值填入视图对应字段。
func fillChannelView(v *NotifyChannelView, chType string, vals map[string]string) {
switch chType {
case ChannelWebhook:
v.URL, v.BodyTemplate = vals[settingChWebhookURL], vals[settingChWebhookBodyTpl]
case ChannelNtfy:
v.Server, v.Topic = vals[settingChNtfyServer], vals[settingChNtfyTopic]
case ChannelBark:
v.Server = vals[settingChBarkServer]
case ChannelSMTP:
v.Host, v.Username = vals[settingChSMTPHost], vals[settingChSMTPUsername]
v.From, v.To = vals[settingChSMTPFrom], vals[settingChSMTPTo]
v.Port, _ = strconv.Atoi(vals[settingChSMTPPort])
}
}
// UpdateNotifyChannel 保存单渠道配置;字段先经 validateChannelInput 校验。
func (s *SettingService) UpdateNotifyChannel(ctx context.Context, chType string, in UpdateNotifyChannelInput) error {
enabledKey, _, secretKey := notifyChannelKeys(chType)
if enabledKey == "" {
return ErrUnknownNotifyChannel
}
if err := validateChannelInput(chType, in); err != nil {
return err
}
pairs := channelPlainPairs(chType, in)
pairs[enabledKey] = boolSetting(in.Enabled)
for k, v := range pairs {
if err := s.set(ctx, k, v); err != nil {
return err
}
}
if secretKey == "" || in.Secret == nil {
return nil
}
return s.encryptSetting(ctx, secretKey, *in.Secret)
}
// channelPlainPairs 返回渠道明文键与入参值的对应关系。
func channelPlainPairs(chType string, in UpdateNotifyChannelInput) map[string]string {
switch chType {
case ChannelWebhook:
return map[string]string{settingChWebhookURL: in.URL, settingChWebhookBodyTpl: in.BodyTemplate}
case ChannelNtfy:
return map[string]string{settingChNtfyServer: in.Server, settingChNtfyTopic: in.Topic}
case ChannelBark:
return map[string]string{settingChBarkServer: in.Server}
case ChannelSMTP:
return map[string]string{settingChSMTPHost: in.Host, settingChSMTPPort: strconv.Itoa(in.Port),
settingChSMTPUsername: in.Username, settingChSMTPFrom: in.From, settingChSMTPTo: in.To}
}
return nil
}
// validateChannelInput 按渠道校验入参;仅在启用时强校验必填,便于「先存草稿后启用」。
func validateChannelInput(chType string, in UpdateNotifyChannelInput) error {
if !in.Enabled {
return validateChannelURLs(chType, in)
}
switch chType {
case ChannelWebhook:
if strings.TrimSpace(in.URL) == "" {
return fmt.Errorf("启用 Webhook 须填写 URL")
}
case ChannelNtfy:
if strings.TrimSpace(in.Topic) == "" {
return fmt.Errorf("启用 ntfy 须填写 topic")
}
case ChannelSMTP:
if in.Host == "" || in.Port <= 0 || in.Port > 65535 || in.From == "" || in.To == "" {
return fmt.Errorf("启用 SMTP 须填写主机、端口(1-65535)、发件人与收件人")
}
}
return validateChannelURLs(chType, in)
}
// validateChannelURLs 校验渠道内的 URL 形字段(非空才校验)。
func validateChannelURLs(chType string, in UpdateNotifyChannelInput) error {
if chType == ChannelWebhook && in.URL != "" {
return validNotifyURL(in.URL)
}
if (chType == ChannelNtfy || chType == ChannelBark) && in.Server != "" {
return validNotifyURL(in.Server)
}
return nil
}
// boolSetting 把布尔序列化为设置值 "1"/"0"。
func boolSetting(b bool) string {
if b {
return "1"
}
return "0"
}
// encryptSetting 加密保存设置值;空串直接落空值表示清除。
func (s *SettingService) encryptSetting(ctx context.Context, key, value string) error {
if value == "" {
return s.set(ctx, key, "")
}
enc, err := s.cipher.EncryptString(value)
if err != nil {
return fmt.Errorf("encrypt setting %s: %w", key, err)
}
return s.set(ctx, key, enc)
}
// decryptSetting 读取并解密设置值;未配置返回空串。
func (s *SettingService) decryptSetting(ctx context.Context, key string) (string, error) {
enc, err := s.get(ctx, key)
if err != nil || enc == "" {
return "", err
}
val, err := s.cipher.DecryptString(enc)
if err != nil {
return "", fmt.Errorf("decrypt setting %s: %w", key, err)
}
return val, nil
}
// webhookChannel 读取解密后的 Webhook 配置,供发送端消费。
func (s *SettingService) webhookChannel(ctx context.Context) (WebhookChannel, error) {
vals, err := s.getMany(ctx, settingChWebhookEnabled, settingChWebhookURL, settingChWebhookBodyTpl)
if err != nil {
return WebhookChannel{}, err
}
return WebhookChannel{
Enabled: vals[settingChWebhookEnabled] == "1",
URL: vals[settingChWebhookURL],
BodyTemplate: vals[settingChWebhookBodyTpl],
}, nil
}
// ntfyChannel 读取解密后的 ntfy 配置。
func (s *SettingService) ntfyChannel(ctx context.Context) (NtfyChannel, error) {
vals, err := s.getMany(ctx, settingChNtfyEnabled, settingChNtfyServer, settingChNtfyTopic)
if err != nil {
return NtfyChannel{}, err
}
token, err := s.decryptSetting(ctx, settingChNtfyToken)
if err != nil {
return NtfyChannel{}, err
}
return NtfyChannel{
Enabled: vals[settingChNtfyEnabled] == "1",
Server: vals[settingChNtfyServer],
Topic: vals[settingChNtfyTopic],
Token: token,
}, nil
}
// barkChannel 读取解密后的 Bark 配置。
func (s *SettingService) barkChannel(ctx context.Context) (BarkChannel, error) {
vals, err := s.getMany(ctx, settingChBarkEnabled, settingChBarkServer)
if err != nil {
return BarkChannel{}, err
}
key, err := s.decryptSetting(ctx, settingChBarkDeviceKey)
if err != nil {
return BarkChannel{}, err
}
return BarkChannel{
Enabled: vals[settingChBarkEnabled] == "1",
Server: vals[settingChBarkServer],
DeviceKey: key,
}, nil
}
// smtpChannel 读取解密后的 SMTP 配置。
func (s *SettingService) smtpChannel(ctx context.Context) (SMTPChannel, error) {
vals, err := s.getMany(ctx, settingChSMTPEnabled, settingChSMTPHost, settingChSMTPPort,
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo)
if err != nil {
return SMTPChannel{}, err
}
password, err := s.decryptSetting(ctx, settingChSMTPPassword)
if err != nil {
return SMTPChannel{}, err
}
port, _ := strconv.Atoi(vals[settingChSMTPPort])
return SMTPChannel{
Enabled: vals[settingChSMTPEnabled] == "1",
Host: vals[settingChSMTPHost],
Port: port,
Username: vals[settingChSMTPUsername],
Password: password,
From: vals[settingChSMTPFrom],
To: vals[settingChSMTPTo],
}, nil
}
+299 -16
View File
@@ -3,7 +3,9 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
@@ -22,6 +24,13 @@ const taskRunTimeout = 10 * time.Minute
// taskLogKeep 是每个任务保留的执行日志条数。
const taskLogKeep = 100
// defaultSnatchIPWait 是抢机成功后等待公网 IPv4 分配的总预算(全部实例共享,
// 实例从创建到 VNIC 就绪通常 1~2 分钟);taskRunTimeout 余量内,超时降级不阻塞通知。
const defaultSnatchIPWait = 3 * time.Minute
// snatchIPPollInterval 是等待公网 IP 的轮询间隔。
const snatchIPPollInterval = 10 * time.Second
// TaskService 管理后台任务的存储、cron 调度与执行。
type TaskService struct {
db *gorm.DB
@@ -31,21 +40,39 @@ type TaskService struct {
cron *cron.Cron
// aiGateway 供 AI 探测任务执行渠道探测,由 main 装配(可为 nil)
aiGateway *AiGatewayService
// snatchIPWait 是抢机成功后等待公网 IP 的预算,测试置 0 跳过等待
snatchIPWait time.Duration
mu sync.Mutex
entries map[uint]cron.EntryID
// runMu 让租户删除等待在途执行结束,并阻止新执行读取删除前的任务快照。
runMu sync.RWMutex
// runningMu 保护 running:同一任务不允许并发重复执行
// (cron 重叠触发静默跳过,手动触发返回 ErrTaskRunning)
runningMu sync.Mutex
running map[uint]bool
// runWG 追踪手动触发的后台执行,Stop 时等待收尾
runWG sync.WaitGroup
// snatchVarsMu 保护 snatchVars:runSnatch 抢满时写入成功通知变量,
// execute 组装执行后快照时取走(一次性)
snatchVarsMu sync.Mutex
snatchVars map[uint]map[string]string
}
// NewTaskService 组装依赖;notifier 传 nil 表示整体关闭通知,
// settings 供发送前按事件类型过滤(nil 视为全开)。调用 Start 后开始调度。
func NewTaskService(db *gorm.DB, configs *OciConfigService, notifier *Notifier, settings *SettingService) *TaskService {
return &TaskService{
db: db,
configs: configs,
notifier: notifier,
settings: settings,
cron: cron.New(),
entries: map[uint]cron.EntryID{},
db: db,
configs: configs,
notifier: notifier,
settings: settings,
cron: cron.New(),
entries: map[uint]cron.EntryID{},
running: map[uint]bool{},
snatchIPWait: defaultSnatchIPWait,
snatchVars: map[uint]map[string]string{},
}
}
@@ -67,10 +94,11 @@ func (s *TaskService) Start() error {
return nil
}
// Stop 停止调度并等待执行中的任务收尾
// Stop 停止调度并等待执行中的任务收尾(含手动触发的后台执行),
// 保证任务产生的异步通知都已进入 Notifier 的等待队列。
func (s *TaskService) Stop() {
<-s.cron.Stop().Done()
s.runWG.Wait()
}
// healthCheckPayload 是测活任务参数;ociConfigIds 为空表示全部配置。
@@ -337,7 +365,7 @@ func (s *TaskService) TaskLogs(ctx context.Context, id uint, limit int) ([]model
return logs, nil
}
// RunTaskNow 立即执行一次任务并返回本次日志。
// RunTaskNow 同步执行一次任务并返回本次日志(内部与测试使用;API 走 TriggerTask)
func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog, error) {
if _, err := s.GetTask(ctx, id); err != nil {
return nil, err
@@ -345,6 +373,43 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog,
return s.execute(id), nil
}
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
if _, err := s.GetTask(ctx, id); err != nil {
return err
}
if !s.beginRun(id) {
return ErrTaskRunning
}
s.runWG.Add(1)
go func() {
defer s.runWG.Done()
s.runHeld(id)
}()
return nil
}
// beginRun 抢占任务的执行权;在途时返回 false。
func (s *TaskService) beginRun(id uint) bool {
s.runningMu.Lock()
defer s.runningMu.Unlock()
if s.running[id] {
return false
}
s.running[id] = true
return true
}
func (s *TaskService) endRun(id uint) {
s.runningMu.Lock()
delete(s.running, id)
s.runningMu.Unlock()
}
// schedule 把任务注册进 cron 调度。
func (s *TaskService) schedule(task *model.Task) error {
s.mu.Lock()
@@ -371,9 +436,46 @@ func (s *TaskService) unschedule(taskID uint) {
}
}
// execute 执行一次任务:加载 → 分派 → 更新任务状态并写日志,
// 前后状态交给通知判定,只在状态变化时推送。
// ApplyTenantCleanup 在租户删除事务提交后注销已删除任务并对齐 AI 探测任务。
func (s *TaskService) ApplyTenantCleanup(ctx context.Context, taskIDs []uint, channelsGone bool) {
for _, id := range taskIDs {
s.unschedule(id)
s.forgetSnatchVars(id)
}
if channelsGone {
s.SyncAiProbeTask(ctx)
}
}
func (s *TaskService) forgetSnatchVars(taskID uint) {
s.snatchVarsMu.Lock()
delete(s.snatchVars, taskID)
s.snatchVarsMu.Unlock()
}
func (s *TaskService) lockTenantCleanup() func() {
s.runMu.Lock()
return s.runMu.Unlock
}
// execute 执行一次任务:同一任务已有执行在途时静默跳过(cron 重叠触发防抖)。
func (s *TaskService) execute(taskID uint) *model.TaskLog {
if !s.beginRun(taskID) {
return nil
}
return s.runHeld(taskID)
}
// runHeld 在已持有执行权的前提下完成一次执行:加载 → 分派 → 更新任务状态并写日志,
// 前后状态交给通知判定,只在状态变化时推送。
func (s *TaskService) runHeld(taskID uint) *model.TaskLog {
defer s.endRun(taskID)
s.runMu.RLock()
defer s.runMu.RUnlock()
return s.executeLocked(taskID)
}
func (s *TaskService) executeLocked(taskID uint) *model.TaskLog {
ctx, cancel := context.WithTimeout(context.Background(), taskRunTimeout)
defer cancel()
task, err := s.GetTask(ctx, taskID)
@@ -391,12 +493,40 @@ func (s *TaskService) execute(taskID uint) *model.TaskLog {
task.LastError = oci.CompactError(runErr)
message = task.LastError
}
s.db.Save(task)
cur := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError, Message: message}
if !s.storeTaskRun(ctx, task) {
return nil
}
cur := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError,
Message: message, SnatchVars: s.takeSnatchVars(task.ID)}
s.notify(notifyEvents(prev, cur))
return s.appendLog(task.ID, runErr == nil, message, time.Since(start))
}
func (s *TaskService) storeTaskRun(ctx context.Context, task *model.Task) bool {
stored, err := s.persistTaskRun(ctx, task)
if err != nil {
log.Printf("persist task run: %v", err)
return false
}
return stored
}
func (s *TaskService) persistTaskRun(ctx context.Context, task *model.Task) (bool, error) {
updates := map[string]any{
"status": task.Status, "last_run_at": task.LastRunAt,
"last_error": task.LastError, "run_count": task.RunCount,
}
if task.Type == model.TaskTypeSnatch {
updates["payload"] = task.Payload
}
res := s.db.WithContext(ctx).Model(&model.Task{}).
Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).Updates(updates)
if res.Error != nil {
return false, fmt.Errorf("persist task %d run: %w", task.ID, res.Error)
}
return res.RowsAffected == 1, nil
}
// run 按类型分派任务执行。
func (s *TaskService) run(ctx context.Context, task *model.Task) (string, error) {
switch task.Type {
@@ -650,12 +780,158 @@ func (s *TaskService) runSnatch(ctx context.Context, task *model.Task) (string,
writeSnatchPayload(task, &p)
return fmt.Sprintf("created %d (%s)%s, %d remaining", len(instances), strings.Join(ids, ","), adNote, remaining), nil
}
task.Status = model.TaskStatusSucceeded
writeSnatchPayload(task, &p)
s.unschedule(task.ID)
s.snatchSucceed(ctx, task, &p, in, instances)
return fmt.Sprintf("created %d: %s%s", len(instances), strings.Join(ids, ","), adNote), nil
}
// snatchSucceed 处理抢满收尾:标记成功、停止调度,并组装成功通知变量暂存,
// 供 execute 发通知时合并进 snatch_success 事件。
func (s *TaskService) snatchSucceed(ctx context.Context, task *model.Task, p *snatchPayload, in oci.CreateInstanceInput, instances []oci.Instance) {
task.Status = model.TaskStatusSucceeded
writeSnatchPayload(task, p)
s.unschedule(task.ID)
s.storeSnatchVars(task.ID, s.snatchSuccessVars(ctx, p, in, instances))
}
// snatchSuccessVars 组装抢机成功通知的模板变量;单项查询失败以占位值回退,
// 绝不阻塞通知发送。密码只进通知变量,不落任务日志。
func (s *TaskService) snatchSuccessVars(ctx context.Context, p *snatchPayload, in oci.CreateInstanceInput, instances []oci.Instance) map[string]string {
tenant, region := "-", in.Region
if cfg, err := s.configs.Get(ctx, p.OciConfigID); err == nil {
tenant = cfg.Alias
if region == "" {
region = cfg.Region
}
}
ips, pwds := s.snatchInstanceNet(ctx, p.OciConfigID, region, in, instances)
return map[string]string{
"tenant": tenant,
"region": regionLabel(region),
"shape": in.Shape,
"spec": specLabel(in),
"image": s.snatchImageLabel(ctx, p.OciConfigID, region, in),
"ip": ips,
"root_password": pwds,
}
}
// snatchInstanceNet 逐台等待公网 IPv4 并取回 root 密码;全部实例共享
// snatchIPWait 总预算,多台结果按创建顺序以「、」连接。
func (s *TaskService) snatchInstanceNet(ctx context.Context, cfgID uint, region string, in oci.CreateInstanceInput, instances []oci.Instance) (string, string) {
deadline := time.Now().Add(s.snatchIPWait)
ips := make([]string, 0, len(instances))
pwds := make([]string, 0, len(instances))
for _, inst := range instances {
ips = append(ips, s.waitPublicIPv4(ctx, cfgID, region, inst.ID, deadline))
pwds = append(pwds, rootPasswordOf(in, inst))
}
return strings.Join(ips, "、"), strings.Join(pwds, "、")
}
// waitPublicIPv4 轮询实例公网 IPv4 直至分配或超过 deadline;
// 超时回退内网 IPv4(标注内网),再无则「待分配」。
func (s *TaskService) waitPublicIPv4(ctx context.Context, cfgID uint, region, instanceID string, deadline time.Time) string {
private := ""
for {
inst, err := s.configs.Instance(ctx, cfgID, region, instanceID)
if err == nil {
if inst.PublicIP != "" {
return inst.PublicIP
}
if inst.PrivateIP != "" {
private = inst.PrivateIP
}
}
if time.Now().After(deadline) {
break
}
select {
case <-ctx.Done():
return waitIPFallback(private)
case <-time.After(snatchIPPollInterval):
}
}
return waitIPFallback(private)
}
// waitIPFallback 是公网 IP 等待超时后的展示回退。
func waitIPFallback(private string) string {
if private != "" {
return private + "(内网)"
}
return "待分配"
}
// rootPasswordOf 取单台实例的 root 密码:随机生成模式从创建时写入的
// FreeformTags 回读(每台独立),显式密码模式各台相同;SSH 密钥模式无密码。
func rootPasswordOf(in oci.CreateInstanceInput, inst oci.Instance) string {
if pwd := inst.FreeformTags["RootPassword"]; pwd != "" {
return pwd
}
if in.RootPassword != "" {
return in.RootPassword
}
return "—(SSH 密钥登录)"
}
// specLabel 拼 Flex 规格,如 4C/24G;固定规格 shape(无 ocpus 参数)为 -。
func specLabel(in oci.CreateInstanceInput) string {
if in.Ocpus <= 0 {
return "-"
}
return fmt.Sprintf("%gC/%gG", in.Ocpus, in.MemoryInGBs)
}
// regionLabel 把区域标识换成控制台风格别名展示,如「Japan Central (Osaka)」;
// 区域名与三字码均可匹配,表中未收录时回退原值。
func regionLabel(region string) string {
if region == "" {
return "-"
}
regions, err := oci.AllRegions()
if err != nil {
return region
}
for _, r := range regions {
if strings.EqualFold(r.Name, region) || strings.EqualFold(r.Key, region) {
return r.Alias
}
}
return region
}
// snatchImageLabel 返回启动源展示名:镜像查显示名(失败回退 OCID),
// 引导卷启动源固定文案。
func (s *TaskService) snatchImageLabel(ctx context.Context, cfgID uint, region string, in oci.CreateInstanceInput) string {
if in.BootVolumeID != "" {
return "引导卷"
}
if in.ImageID == "" {
return "-"
}
img, err := s.configs.Image(ctx, cfgID, region, in.ImageID)
if err != nil || img.DisplayName == "" {
return in.ImageID
}
return img.DisplayName
}
// storeSnatchVars 暂存抢机成功通知变量,由 takeSnatchVars 取走。
func (s *TaskService) storeSnatchVars(taskID uint, vars map[string]string) {
s.snatchVarsMu.Lock()
defer s.snatchVarsMu.Unlock()
s.snatchVars[taskID] = vars
}
// takeSnatchVars 取走并清除暂存的成功通知变量(无则 nil)。
func (s *TaskService) takeSnatchVars(taskID uint) map[string]string {
s.snatchVarsMu.Lock()
defer s.snatchVarsMu.Unlock()
vars := s.snatchVars[taskID]
delete(s.snatchVars, taskID)
return vars
}
// snatchInstanceInput 组装本次创建参数:可用域显式指定时原样使用;
// 留空(自动)时按执行序号轮询区域全部可用域——ad-1、ad-2、ad-3 依次循环,
// 分摊单可用域容量不足。附加说明串供执行日志展示本次所用可用域。
@@ -735,6 +1011,9 @@ type taskSnapshot struct {
Status string
LastError string
Message string // 本次执行结果摘要,仅执行后快照填写
// SnatchVars 是抢机成功通知的补充变量(租户/区域/实例明细),
// 仅抢满那次执行后快照携带
SnatchVars map[string]string
}
// notifyKind 是通知事件类型,与设置页「通知管理」开关一一对应。
@@ -760,7 +1039,11 @@ type notifyEvent struct {
func notifyEvents(prev, cur taskSnapshot) []notifyEvent {
var events []notifyEvent
if prev.Status != model.TaskStatusSucceeded && cur.Status == model.TaskStatusSucceeded {
events = append(events, notifyEvent{notifySnatchSuccess, map[string]string{"task_name": cur.Name, "message": cur.Message}})
vars := map[string]string{"task_name": cur.Name, "message": cur.Message}
for k, v := range cur.SnatchVars {
vars[k] = v
}
events = append(events, notifyEvent{notifySnatchSuccess, vars})
}
switch {
case prev.Status != model.TaskStatusFailed && cur.Status == model.TaskStatusFailed:
+137 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
@@ -80,7 +81,10 @@ func newTaskEnv(t *testing.T, client oci.Client) (*TaskService, *OciConfigServic
t.Fatalf("new cipher: %v", err)
}
configs := NewOciConfigService(db, cipher, client)
return NewTaskService(db, configs, nil, NewSettingService(db, cipher)), configs, db
tasks := NewTaskService(db, configs, nil, NewSettingService(db, cipher))
// 测试不等待公网 IP 分配:预算置 0,首查后立即回退
tasks.snatchIPWait = 0
return tasks, configs, db
}
func TestCreateTaskValidation(t *testing.T) {
@@ -699,3 +703,135 @@ func TestUpdateTaskFailedToActiveResetsAuthFail(t *testing.T) {
t.Error("重新启用后任务未回到调度")
}
}
// TestSnatchSuccessVars 锁定抢机成功通知变量的组装:租户/区域/类型/镜像,
// 以及 root 密码三种凭据模式与 IP 逐级回退;单项查询失败不阻塞其余字段。
func TestSnatchSuccessVars(t *testing.T) {
tests := []struct {
name string
client *fakeClient
in oci.CreateInstanceInput
insts []oci.Instance
want map[string]string
}{
{
name: "生成密码模式全字段",
client: &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
instance: oci.Instance{PublicIP: "203.0.113.7"},
image: oci.Image{DisplayName: "Ubuntu-24.04"},
},
in: oci.CreateInstanceInput{Region: "ap-osaka-1", Shape: "VM.Standard.A1.Flex",
Ocpus: 4, MemoryInGBs: 24, ImageID: "ocid1.image.oc1..img"},
insts: []oci.Instance{{ID: "i1", FreeformTags: map[string]string{"RootPassword": "pw-from-tag"}}},
want: map[string]string{
"tenant": "试用期",
"region": "Japan Central (Osaka)",
"shape": "VM.Standard.A1.Flex",
"spec": "4C/24G",
"image": "Ubuntu-24.04",
"ip": "203.0.113.7",
"root_password": "pw-from-tag",
},
},
{
name: "显式密码_未知区域_IP回退内网_镜像失败回退OCID",
client: &fakeClient{
instance: oci.Instance{PrivateIP: "10.0.0.5"},
imageErr: fmt.Errorf("boom"),
},
in: oci.CreateInstanceInput{Region: "xx-nowhere-1", Shape: "VM.Standard.E2.1.Micro",
ImageID: "ocid1.image.oc1..img", RootPassword: "explicit-pw"},
insts: []oci.Instance{{ID: "i1"}},
want: map[string]string{
"tenant": "试用期",
"region": "xx-nowhere-1",
"shape": "VM.Standard.E2.1.Micro",
"spec": "-",
"image": "ocid1.image.oc1..img",
"ip": "10.0.0.5(内网)",
"root_password": "explicit-pw",
},
},
{
name: "SSH模式_引导卷_区域取配置默认_多台待分配",
client: &fakeClient{instanceErr: fmt.Errorf("boom")},
in: oci.CreateInstanceInput{Shape: "s",
BootVolumeID: "ocid1.bootvolume.oc1..bv", SSHPublicKey: "ssh-ed25519 AAAA"},
insts: []oci.Instance{{ID: "i1"}, {ID: "i2"}},
want: map[string]string{
"region": "Germany Central (Frankfurt)",
"image": "引导卷",
"ip": "待分配、待分配",
"root_password": "—(SSH 密钥登录)、—(SSH 密钥登录)",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tasks, configs, _ := newTaskEnv(t, tt.client)
cfg, err := configs.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("import: %v", err)
}
got := tasks.snatchSuccessVars(context.Background(),
&snatchPayload{OciConfigID: cfg.ID}, tt.in, tt.insts)
for k, want := range tt.want {
if got[k] != want {
t.Errorf("%s = %q, want %q", k, got[k], want)
}
}
})
}
}
// TestNotifyEventsSnatchVarsMerged 抢满成功事件应合并暂存的补充变量。
func TestNotifyEventsSnatchVarsMerged(t *testing.T) {
prev := taskSnapshot{Name: "抢机", Status: model.TaskStatusActive}
cur := taskSnapshot{Name: "抢机", Status: model.TaskStatusSucceeded, Message: "created 1",
SnatchVars: map[string]string{"tenant": "免费01·t", "ip": "203.0.113.7", "root_password": "pw"}}
events := notifyEvents(prev, cur)
if len(events) != 1 || events[0].Kind != notifySnatchSuccess {
t.Fatalf("events = %+v, want 1 条 snatch_success", events)
}
want := map[string]string{"task_name": "抢机", "message": "created 1",
"tenant": "免费01·t", "ip": "203.0.113.7", "root_password": "pw"}
for k, v := range want {
if events[0].Vars[k] != v {
t.Errorf("vars[%s] = %q, want %q", k, events[0].Vars[k], v)
}
}
}
func TestTriggerTaskAsyncAndDedup(t *testing.T) {
client := &blockingClient{fakeClient: &fakeClient{}, started: make(chan struct{}, 1), release: make(chan struct{})}
configs, tasks, db := newTenantDeleteEnv(t, client)
target, _ := seedDeleteTenants(t, db)
setTenantPrivateKey(t, configs, target.ID)
task := createHealthTask(t, tasks, target.ID)
ctx := context.Background()
// 触发立即返回,执行在后台开始
if err := tasks.TriggerTask(ctx, task.ID); err != nil {
t.Fatalf("TriggerTask: %v", err)
}
<-client.started
// 在途重复触发被拒;cron 重叠执行静默跳过
if err := tasks.TriggerTask(ctx, task.ID); !errors.Is(err, ErrTaskRunning) {
t.Errorf("在途重复触发应返回 ErrTaskRunning: %v", err)
}
if entry := tasks.execute(task.ID); entry != nil {
t.Error("在途时 cron 重叠执行应静默跳过")
}
close(client.release)
tasks.Stop() // 等待后台执行收尾
assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{task.ID}, 1)
// 执行权已释放,可再次触发;不存在的任务报错
if !tasks.beginRun(task.ID) {
t.Error("执行结束后应可重新获得执行权")
}
tasks.endRun(task.ID)
if err := tasks.TriggerTask(ctx, 9999); err == nil {
t.Error("不存在的任务应报错")
}
}
+37 -28
View File
@@ -31,27 +31,36 @@ func (s *OciConfigService) credentialsAndHomeRegion(ctx context.Context, id uint
return cred, homeRegionOf(cfg), nil
}
// TenantUsers 列出租户 IAM 用户
func (s *OciConfigService) TenantUsers(ctx context.Context, id uint) ([]oci.TenantUser, error) {
cred, err := s.credentialsByID(ctx, id)
// IdentityDomains 列出租户全部 ACTIVE 身份域(域选择器数据源)
func (s *OciConfigService) IdentityDomains(ctx context.Context, id uint) ([]oci.IdentityDomain, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListTenantUsers(ctx, cred)
return s.client.ListIdentityDomains(ctx, cred, homeRegion)
}
// TenantUsers 列出租户 IAM 用户;domainID 非空只列该身份域。
func (s *OciConfigService) TenantUsers(ctx context.Context, id uint, domainID string) ([]oci.TenantUser, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListTenantUsers(ctx, cred, homeRegion, domainID)
}
// TenantUserDetail 查用户的域档案与管理员状态(编辑表单预填充用)。
func (s *OciConfigService) TenantUserDetail(ctx context.Context, id uint, userID string) (oci.TenantUserDetail, error) {
func (s *OciConfigService) TenantUserDetail(ctx context.Context, id uint, domainID, userID string) (oci.TenantUserDetail, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.TenantUserDetail{}, err
}
return s.client.GetTenantUserDetail(ctx, cred, homeRegion, userID)
return s.client.GetTenantUserDetail(ctx, cred, homeRegion, domainID, userID)
}
// CreateTenantUser 新增用户;名字与姓氏必填(域用户档案要求),
// 描述缺省用「名 姓」。
func (s *OciConfigService) CreateTenantUser(ctx context.Context, id uint, in oci.CreateTenantUserInput) (oci.TenantUser, error) {
func (s *OciConfigService) CreateTenantUser(ctx context.Context, id uint, domainID string, in oci.CreateTenantUserInput) (oci.TenantUser, error) {
if strings.TrimSpace(in.Name) == "" {
return oci.TenantUser{}, fmt.Errorf("create tenant user: name is required")
}
@@ -65,11 +74,11 @@ func (s *OciConfigService) CreateTenantUser(ctx context.Context, id uint, in oci
if err != nil {
return oci.TenantUser{}, err
}
return s.client.CreateTenantUser(ctx, cred, homeRegion, in)
return s.client.CreateTenantUser(ctx, cred, homeRegion, domainID, in)
}
// UpdateTenantUser 编辑用户资料;nil 字段不修改,全 nil 报错。
func (s *OciConfigService) UpdateTenantUser(ctx context.Context, id uint, userID string, in oci.UpdateTenantUserInput) (oci.TenantUser, error) {
func (s *OciConfigService) UpdateTenantUser(ctx context.Context, id uint, domainID, userID string, in oci.UpdateTenantUserInput) (oci.TenantUser, error) {
hasField := in.Description != nil || in.Email != nil || in.GivenName != nil || in.FamilyName != nil
hasAdmin := in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil
if !hasField && !hasAdmin {
@@ -79,29 +88,29 @@ func (s *OciConfigService) UpdateTenantUser(ctx context.Context, id uint, userID
if err != nil {
return oci.TenantUser{}, err
}
return s.client.UpdateTenantUser(ctx, cred, homeRegion, userID, in)
return s.client.UpdateTenantUser(ctx, cred, homeRegion, domainID, userID, in)
}
// IdentitySetting 读取域身份设置(当前只透出主邮箱必填开关)。
func (s *OciConfigService) IdentitySetting(ctx context.Context, id uint) (oci.IdentitySettingInfo, error) {
func (s *OciConfigService) IdentitySetting(ctx context.Context, id uint, domainID string) (oci.IdentitySettingInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentitySettingInfo{}, err
}
return s.client.GetIdentitySetting(ctx, cred, homeRegion)
return s.client.GetIdentitySetting(ctx, cred, homeRegion, domainID)
}
// UpdateIdentitySetting 修改「用户需要提供主电子邮件地址」开关。
func (s *OciConfigService) UpdateIdentitySetting(ctx context.Context, id uint, primaryEmailRequired bool) (oci.IdentitySettingInfo, error) {
func (s *OciConfigService) UpdateIdentitySetting(ctx context.Context, id uint, domainID string, primaryEmailRequired bool) (oci.IdentitySettingInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.IdentitySettingInfo{}, err
}
return s.client.UpdateIdentitySetting(ctx, cred, homeRegion, primaryEmailRequired)
return s.client.UpdateIdentitySetting(ctx, cred, homeRegion, domainID, primaryEmailRequired)
}
// DeleteTenantUser 删除 IAM 用户;拒绝删除当前配置正在使用的用户。
func (s *OciConfigService) DeleteTenantUser(ctx context.Context, id uint, userID string) error {
func (s *OciConfigService) DeleteTenantUser(ctx context.Context, id uint, domainID, userID string) error {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
@@ -109,25 +118,25 @@ func (s *OciConfigService) DeleteTenantUser(ctx context.Context, id uint, userID
if userID == cred.UserOCID {
return fmt.Errorf("delete tenant user: refusing to delete the user this config signs requests with")
}
return s.client.DeleteTenantUser(ctx, cred, homeRegion, userID)
return s.client.DeleteTenantUser(ctx, cred, homeRegion, domainID, userID)
}
// ResetTenantUserPassword 重置用户控制台密码,返回一次性新密码。
func (s *OciConfigService) ResetTenantUserPassword(ctx context.Context, id uint, userID string) (string, error) {
func (s *OciConfigService) ResetTenantUserPassword(ctx context.Context, id uint, domainID, userID string) (string, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return "", err
}
return s.client.ResetTenantUserPassword(ctx, cred, homeRegion, userID)
return s.client.ResetTenantUserPassword(ctx, cred, homeRegion, domainID, userID)
}
// DeleteTenantUserMfa 清除用户全部 MFA,返回删除的经典 TOTP 设备数。
func (s *OciConfigService) DeleteTenantUserMfa(ctx context.Context, id uint, userID string) (int, error) {
func (s *OciConfigService) DeleteTenantUserMfa(ctx context.Context, id uint, domainID, userID string) (int, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return 0, err
}
return s.client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, userID)
return s.client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, domainID, userID)
}
// DeleteTenantUserApiKeys 删除用户的 API Key;默认保留当前配置使用的指纹。
@@ -140,17 +149,17 @@ func (s *OciConfigService) DeleteTenantUserApiKeys(ctx context.Context, id uint,
}
// NotificationRecipients 查询域通知收件人设置。
func (s *OciConfigService) NotificationRecipients(ctx context.Context, id uint) (oci.NotificationRecipients, error) {
func (s *OciConfigService) NotificationRecipients(ctx context.Context, id uint, domainID string) (oci.NotificationRecipients, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return oci.NotificationRecipients{}, err
}
return s.client.GetNotificationRecipients(ctx, cred, homeRegion)
return s.client.GetNotificationRecipients(ctx, cred, homeRegion, domainID)
}
// UpdateNotificationRecipients 把域通知改为只发给指定收件人;
// 收件人为空表示关闭 test mode 恢复默认发送。
func (s *OciConfigService) UpdateNotificationRecipients(ctx context.Context, id uint, emails []string) (oci.NotificationRecipients, error) {
func (s *OciConfigService) UpdateNotificationRecipients(ctx context.Context, id uint, domainID string, emails []string) (oci.NotificationRecipients, error) {
if err := validateEmails(emails); err != nil {
return oci.NotificationRecipients{}, err
}
@@ -158,7 +167,7 @@ func (s *OciConfigService) UpdateNotificationRecipients(ctx context.Context, id
if err != nil {
return oci.NotificationRecipients{}, err
}
return s.client.UpdateNotificationRecipients(ctx, cred, homeRegion, emails)
return s.client.UpdateNotificationRecipients(ctx, cred, homeRegion, domainID, emails)
}
func validateEmails(emails []string) error {
@@ -171,16 +180,16 @@ func validateEmails(emails []string) error {
}
// PasswordPolicies 列出域密码策略。
func (s *OciConfigService) PasswordPolicies(ctx context.Context, id uint) ([]oci.PasswordPolicyInfo, error) {
func (s *OciConfigService) PasswordPolicies(ctx context.Context, id uint, domainID string) ([]oci.PasswordPolicyInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListPasswordPolicies(ctx, cred, homeRegion)
return s.client.ListPasswordPolicies(ctx, cred, homeRegion, domainID)
}
// UpdatePasswordPolicy 修改域密码策略的过期天数或最小长度。
func (s *OciConfigService) UpdatePasswordPolicy(ctx context.Context, id uint, policyID string, in oci.UpdatePasswordPolicyInput) (oci.PasswordPolicyInfo, error) {
func (s *OciConfigService) UpdatePasswordPolicy(ctx context.Context, id uint, domainID, policyID string, in oci.UpdatePasswordPolicyInput) (oci.PasswordPolicyInfo, error) {
if in.PasswordExpiresAfter == nil && in.MinLength == nil {
return oci.PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
}
@@ -188,5 +197,5 @@ func (s *OciConfigService) UpdatePasswordPolicy(ctx context.Context, id uint, po
if err != nil {
return oci.PasswordPolicyInfo{}, err
}
return s.client.UpdatePasswordPolicy(ctx, cred, homeRegion, policyID, in)
return s.client.UpdatePasswordPolicy(ctx, cred, homeRegion, domainID, policyID, in)
}
+460
View File
@@ -0,0 +1,460 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
)
type tenantDeleteResult struct {
config model.OciConfig
deletedTaskIDs []uint
alertRuleIDs []uint
channelsGone bool
}
type tenantTaskAction struct {
task model.Task
deleteTask bool
payload string
}
type tenancyCacheInvalidator interface {
InvalidateTenancy(tenancyOCID string)
}
func (s *OciConfigService) deleteTenant(ctx context.Context, id uint) (*tenantDeleteResult, error) {
result := &tenantDeleteResult{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.deleteTenantInTx(tx, id, result)
})
if err != nil {
return nil, fmt.Errorf("delete oci config %d: %w", id, err)
}
return result, nil
}
func (s *OciConfigService) deleteTenantInTx(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
if err := lockTenant(tx, id, &result.config); err != nil {
return err
}
if err := s.deleteTenantTasks(tx, id, result); err != nil {
return err
}
if err := deleteTenantEvents(tx, id, result); err != nil {
return err
}
if err := deleteTenantAI(tx, id, result); err != nil {
return err
}
if err := deleteTenantSnapshots(tx, id); err != nil {
return err
}
return deleteTenantConfig(tx, id)
}
func lockTenant(tx *gorm.DB, id uint, cfg *model.OciConfig) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(cfg, id).Error
if err != nil {
return fmt.Errorf("load tenant: %w", err)
}
return nil
}
func (s *OciConfigService) deleteTenantTasks(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
var tasks []model.Task
err := tx.Where("type IN ?", []string{
model.TaskTypeSnatch, model.TaskTypeHealthCheck, model.TaskTypeCost,
}).Find(&tasks).Error
if err != nil {
return fmt.Errorf("load tenant tasks: %w", err)
}
for i := range tasks {
action, ok, err := planTenantTask(tasks[i], id)
if err != nil {
return err
}
if !ok {
continue
}
if err := applyTenantTask(tx, action, result); err != nil {
return err
}
}
return nil
}
func planTenantTask(task model.Task, id uint) (tenantTaskAction, bool, error) {
switch task.Type {
case model.TaskTypeSnatch:
return planSnatchTask(task, id)
case model.TaskTypeHealthCheck, model.TaskTypeCost:
return planMultiTenantTask(task, id)
default:
return tenantTaskAction{}, false, nil
}
}
func planSnatchTask(task model.Task, id uint) (tenantTaskAction, bool, error) {
var payload snatchPayload
if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil {
logSkippedTask(task, err)
return tenantTaskAction{}, false, nil
}
if payload.OciConfigID != id {
return tenantTaskAction{}, false, nil
}
return tenantTaskAction{task: task, deleteTask: true}, true, nil
}
// logSkippedTask 记录 payload 无法解析而被跳过的任务:坏数据无法归属租户,
// fail-closed 会永久阻断删除且只能手工修库;改为保留原任务(不删不改)并放行删除。
func logSkippedTask(task model.Task, err error) {
log.Printf("tenant delete: 任务 %d(%s)payload 无法解析,跳过处理: %v", task.ID, task.Type, err)
}
func planMultiTenantTask(task model.Task, id uint) (tenantTaskAction, bool, error) {
payload, err := decodeConfigIDs(task)
if err != nil {
logSkippedTask(task, err)
return tenantTaskAction{}, false, nil
}
if len(payload.OciConfigIDs) == 0 {
return tenantTaskAction{task: task, payload: task.Payload}, true, nil
}
remaining, found := removeConfigID(payload.OciConfigIDs, id)
if !found {
return tenantTaskAction{}, false, nil
}
if len(remaining) == 0 {
return tenantTaskAction{task: task, deleteTask: true}, true, nil
}
payload.OciConfigIDs = remaining
raw, err := json.Marshal(payload)
if err != nil {
return tenantTaskAction{}, false, fmt.Errorf("encode task %d payload: %w", task.ID, err)
}
return tenantTaskAction{task: task, payload: string(raw)}, true, nil
}
func decodeConfigIDs(task model.Task) (healthCheckPayload, error) {
var payload healthCheckPayload
if strings.TrimSpace(task.Payload) == "" {
return payload, nil
}
if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil {
return payload, fmt.Errorf("parse task %d payload: %w", task.ID, err)
}
return payload, nil
}
func removeConfigID(ids []uint, id uint) ([]uint, bool) {
remaining := make([]uint, 0, len(ids))
found := false
for _, candidate := range ids {
if candidate == id {
found = true
continue
}
remaining = append(remaining, candidate)
}
return remaining, found
}
func applyTenantTask(tx *gorm.DB, action tenantTaskAction, result *tenantDeleteResult) error {
if err := deleteTaskHistory(tx, action.task.ID); err != nil {
return err
}
if action.deleteTask {
if err := tx.Delete(&model.Task{}, action.task.ID).Error; err != nil {
return fmt.Errorf("delete task %d: %w", action.task.ID, err)
}
result.deletedTaskIDs = append(result.deletedTaskIDs, action.task.ID)
return nil
}
return resetTenantTask(tx, action)
}
func deleteTaskHistory(tx *gorm.DB, taskID uint) error {
if err := tx.Where("task_id = ?", taskID).Delete(&model.TaskLog{}).Error; err != nil {
return fmt.Errorf("delete task %d logs: %w", taskID, err)
}
key := deadAliasKey(taskID)
if err := tx.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil {
return fmt.Errorf("delete task %d state: %w", taskID, err)
}
return nil
}
func resetTenantTask(tx *gorm.DB, action tenantTaskAction) error {
updates := map[string]any{
"payload": action.payload, "last_run_at": nil,
"last_error": "", "run_count": 0,
}
err := tx.Model(&model.Task{}).Where("id = ?", action.task.ID).Updates(updates).Error
if err != nil {
return fmt.Errorf("reset task %d: %w", action.task.ID, err)
}
return nil
}
func deleteTenantEvents(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
ruleIDs, affectedRules, err := loadTenantEventRefs(tx, id)
if err != nil {
return err
}
if err := deleteAlertHits(tx, id); err != nil {
return err
}
result.alertRuleIDs = mergeIDs(ruleIDs, affectedRules)
if err := deleteWhere(tx, &model.AlertRule{}, "oci_config_id = ?", id); err != nil {
return fmt.Errorf("delete tenant alert rules: %w", err)
}
if err := deleteWhere(tx, &model.LogEvent{}, "oci_config_id = ?", id); err != nil {
return fmt.Errorf("delete tenant log events: %w", err)
}
return nil
}
func loadTenantEventRefs(tx *gorm.DB, id uint) ([]uint, []uint, error) {
ruleIDs, err := lockedTenantRuleIDs(tx, id)
if err != nil {
return nil, nil, fmt.Errorf("load tenant alert rules: %w", err)
}
if err := lockTenantEventRows(tx, id); err != nil {
return nil, nil, fmt.Errorf("load tenant log events: %w", err)
}
affected, err := alertHitRuleIDs(tx, id)
if err != nil {
return nil, nil, err
}
return ruleIDs, affected, nil
}
func lockedTenantRuleIDs(tx *gorm.DB, id uint) ([]uint, error) {
var rows []model.AlertRule
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("oci_config_id = ?", id).Order("id").Find(&rows).Error
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids, err
}
// lockTenantEventRows 对租户全部日志事件行加 FOR UPDATE 锁(SQLite 忽略,
// MySQL/PG 阻塞并发命中写入);事件可达数万,ID 不再回传拼接 SQL,
// 后续删除与关联查询一律用子查询,避免绑定变量上限。
func lockTenantEventRows(tx *gorm.DB, id uint) error {
var rows []model.LogEvent
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("oci_config_id = ?", id).Order("id").Find(&rows).Error
}
// tenantEventIDs 构造「本租户日志事件 ID」子查询,供 IN (?) 内联。
func tenantEventIDs(tx *gorm.DB, id uint) *gorm.DB {
return tx.Model(&model.LogEvent{}).Select("id").Where("oci_config_id = ?", id)
}
// alertHitRuleIDs 找出命中引用了本租户事件的规则 ID(去重,可能含他租户/全局规则)。
func alertHitRuleIDs(tx *gorm.DB, id uint) ([]uint, error) {
ids := make([]uint, 0)
err := tx.Model(&model.AlertRuleHit{}).Where("log_event_id IN (?)", tenantEventIDs(tx, id)).
Distinct().Pluck("rule_id", &ids).Error
if err != nil {
return nil, fmt.Errorf("load affected alert rules: %w", err)
}
return ids, nil
}
func mergeIDs(groups ...[]uint) []uint {
seen := make(map[uint]struct{})
out := make([]uint, 0)
for _, ids := range groups {
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
// deleteAlertHits 删除本租户相关的全部命中:规则属于本租户,或命中引用了
// 本租户的日志事件(他租户/全局规则命中本租户事件的行一并清)。
func deleteAlertHits(tx *gorm.DB, id uint) error {
rules := tx.Model(&model.AlertRule{}).Select("id").Where("oci_config_id = ?", id)
err := tx.Where("rule_id IN (?) OR log_event_id IN (?)", rules, tenantEventIDs(tx, id)).
Delete(&model.AlertRuleHit{}).Error
if err != nil {
return fmt.Errorf("delete tenant alert hits: %w", err)
}
return nil
}
func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
channelIDs, err := lockedAiChannelIDs(tx, id)
if err != nil {
return fmt.Errorf("load tenant AI channels: %w", err)
}
if len(channelIDs) == 0 {
return nil
}
callIDs, err := lockedAiCallIDs(tx, channelIDs)
if err != nil {
return fmt.Errorf("load tenant AI calls: %w", err)
}
if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil {
return err
}
result.channelsGone = true
return reconcileAiProbeRows(tx, result)
}
func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) {
var rows []model.AiChannel
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("oci_config_id = ?", id).Order("id").Find(&rows).Error
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids, err
}
func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) {
var rows []model.AiCallLog
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
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 {
if len(callIDs) > 0 {
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil {
return fmt.Errorf("delete tenant AI content logs: %w", err)
}
}
steps := []struct {
name string
value any
}{
{"AI call logs", &model.AiCallLog{}},
{"AI model cache", &model.AiModelCache{}},
{"AI channels", &model.AiChannel{}},
}
for _, step := range steps {
if err := deleteAIChannelRows(tx, step.value, channelIDs); err != nil {
return fmt.Errorf("delete tenant %s: %w", step.name, err)
}
}
return nil
}
func deleteAIChannelRows(tx *gorm.DB, value any, channelIDs []uint) error {
column := "channel_id"
if _, ok := value.(*model.AiChannel); ok {
column = "id"
}
return deleteWhere(tx, value, column+" IN ?", channelIDs)
}
func reconcileAiProbeRows(tx *gorm.DB, result *tenantDeleteResult) error {
var channelCount int64
if err := tx.Model(&model.AiChannel{}).Count(&channelCount).Error; err != nil {
return fmt.Errorf("count remaining AI channels: %w", err)
}
var ids []uint
err := tx.Model(&model.Task{}).Where("type = ?", model.TaskTypeAiProbe).Pluck("id", &ids).Error
if err != nil {
return fmt.Errorf("load AI probe task: %w", err)
}
for _, id := range ids {
if err := deleteTaskHistory(tx, id); err != nil {
return err
}
if channelCount == 0 {
if err := tx.Delete(&model.Task{}, id).Error; err != nil {
return fmt.Errorf("delete AI probe task %d: %w", id, err)
}
result.deletedTaskIDs = append(result.deletedTaskIDs, id)
continue
}
if err := resetTaskHistoryFields(tx, id); err != nil {
return err
}
}
return nil
}
func resetTaskHistoryFields(tx *gorm.DB, id uint) error {
updates := map[string]any{"last_run_at": nil, "last_error": "", "run_count": 0}
if err := tx.Model(&model.Task{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return fmt.Errorf("reset task %d history: %w", id, err)
}
return nil
}
func deleteTenantSnapshots(tx *gorm.DB, id uint) error {
steps := []struct {
name string
value any
}{
{"check snapshots", &model.CheckSnapshot{}},
{"cost snapshots", &model.CostSnapshot{}},
{"region cache", &model.RegionCache{}},
{"compartment cache", &model.CompartmentCache{}},
}
for _, step := range steps {
if err := deleteWhere(tx, step.value, "oci_config_id = ?", id); err != nil {
return fmt.Errorf("delete tenant %s: %w", step.name, err)
}
}
if err := tx.Where("key = ?", secretKey(id)).Delete(&model.Setting{}).Error; err != nil {
return fmt.Errorf("delete tenant webhook secret: %w", err)
}
return nil
}
func deleteTenantConfig(tx *gorm.DB, id uint) error {
res := tx.Delete(&model.OciConfig{}, id)
if res.Error != nil {
return fmt.Errorf("delete tenant row: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func deleteWhere(tx *gorm.DB, value any, query string, args ...any) error {
return tx.Where(query, args...).Delete(value).Error
}
func (s *OciConfigService) afterTenantDelete(ctx context.Context, result *tenantDeleteResult) {
// 数据已提交,客户端断开不应中断必需的运行时对齐。
ctx = context.WithoutCancel(ctx)
s.InvalidateAuditCache(result.config.ID)
if client, ok := s.client.(tenancyCacheInvalidator); ok {
client.InvalidateTenancy(result.config.TenancyOCID)
}
if s.cleanupEvents != nil {
s.cleanupEvents.ClearAlertCooldown(result.alertRuleIDs)
}
if s.cleanupTasks != nil {
s.cleanupTasks.ApplyTenantCleanup(ctx, result.deletedTaskIDs, result.channelsGone)
}
}

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